============================================================================== WAYFARER HIERARCHY / CONDITIONS / EFFECTS REVIEW ============================================================================== Generated: 2026-09-29 21:57:21 PHP Version: 7.4.33 Project Root: /home/sdllcb5/public_html/wayfarer PURPOSE Current-state review before rebuilding Wayfarer Conditions and Effects into hierarchical, searchable target selectors. PLANNED ARCHITECTURE Target Type -> Specific Target -> Property with contextual search at each applicable hierarchy level. FUTURE TARGET TYPES TO ACCOMMODATE - Storylet navigation - Status - Storylet Subject - Calendar / Time / Season ============================================================================== PHP FILE INVENTORY ============================================================================== admin/builder-refactored.php 570 bytes admin/builder.php 18491 bytes admin/edit.php [NOT FOUND] admin/games/edit.php 41069 bytes admin/games/index.php 1960 bytes config/database.php 546 bytes includes/builder/collection-table.php 3003 bytes includes/builder/collection.php 5438 bytes includes/builder/config-field.php 2577 bytes includes/builder/editor.php 28228 bytes includes/builder/field.php 2083 bytes includes/builder/functions.php 28425 bytes includes/builder/load-record.php 2293 bytes includes/builder/location-tick-effects.php 6712 bytes includes/builder/navigation.php 606 bytes includes/builder/region-hud.php 17876 bytes includes/builder/region-travel.php 6039 bytes includes/builder/relationship-field.php 1485 bytes includes/builder/save-record.php 62859 bytes includes/builder/status-lifecycle.php 34880 bytes includes/builder/storylet-gameplay.php 23326 bytes includes/builder/tick-effects.php 3664 bytes includes/functions.php [NOT FOUND] includes/gameplay/condition-editor.php 15830 bytes includes/gameplay/conditions.php 12840 bytes includes/gameplay/effects-editor.php 34672 bytes includes/gameplay/functions.php 110779 bytes includes/gameplay/game-endings.php 15351 bytes includes/gameplay/starting-choices.php 45072 bytes includes/gameplay/status-lifecycle.php 23965 bytes includes/gameplay/storylet-bindings.php 21050 bytes includes/gameplay/tick-effects-editor.php 30874 bytes includes/gameplay/travel-modifiers.php 12187 bytes includes/header.php 118 bytes includes/runtime.php [NOT FOUND] includes/traits/editor.php 656 bytes includes/traits/functions.php 6839 bytes includes/traits/render.php 20839 bytes play/api/presentation.php 3713 bytes play/api/restart.php 3807 bytes play/api/storylet.php 3812 bytes play/api/tick.php 4594 bytes play/includes/action-screen.php 36965 bytes play/includes/applicable-traits.v15-disabled.php 4163 bytes play/includes/author-mode.php 6109 bytes play/includes/current-region.php 12568 bytes play/includes/effects.php 140164 bytes play/includes/ending-screen.php 3324 bytes play/includes/entity-state.php 22841 bytes play/includes/hud.php 9588 bytes play/includes/player-state.php 10727 bytes play/includes/presentation-screen.php 13543 bytes play/includes/runtime-bootstrap.php 1865 bytes play/includes/runtime.php 69985 bytes play/includes/scene.php 4887 bytes play/includes/setup.php 7700 bytes play/includes/start-screen.php 9123 bytes play/includes/statuses.php 43215 bytes play/includes/storylet-bindings.php 32620 bytes play/includes/storylets.php 43044 bytes play/includes/tick-panel.php 2777 bytes play/index.php 7314 bytes ============================================================================== CURRENT PHP SOURCE ============================================================================== ------------------------------------------------------------------------------ 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 { $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']; ?>
Wayfarer Builder
No games have been created yet.
| Name | Slug | Status | Last Updated | Actions |
|---|---|---|---|---|
| = htmlspecialchars($game['name']) ?> | = htmlspecialchars($game['slug']) ?> | = htmlspecialchars($game['status']) ?> | = htmlspecialchars($game['updated_at']) ?> | Open Builder | Game Settings |
No = strtolower(builderEscape($definition['label'])) ?> have been created yet.
| = builderEscape($column['label'] ?? $columnName) ?> |
|---|
| = builderEscape($displayValue) ?> = builderEscape($displayValue) ?> |
data-condition-relationship="= builderEscape($condition['relationship']) ?>" data-condition-equals="= builderEscape($condition['equals']) ?>" = $initiallyVisible ? '' : 'style="display:none;"' ?> >
------------------------------------------------------------------------------ FILE: 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'] ?? ''); ?>= builderEscape($groupDefinition['help']) ?>
$traitDefinition): ?>
= builderEscape($error) ?>
Storylet duplicated. This copy is saved as Draft.
Permanently delete this = strtolower(builderEscape($definition['singular'])) ?>. This cannot be undone.
------------------------------------------------------------------------------ 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/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 */ $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. */ ?>
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.
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.
No bounded numeric Traits are currently available. A Progress Bar requires a Number Trait with both a Minimum and Maximum.
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.
------------------------------------------------------------------------------ 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. */ 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: 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'))); ?>
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.
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.
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.
= $bindingKey !== ''
? builderEscape(
'{{' .
strtolower($bindingKey) .
'.name}}'
)
: '{{key.name}}' ?>
{{key.name}}
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.
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.
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 ); ?>No child Branches have been created yet.
| Order | Branch | Storylet | Trigger | Status |
|---|---|---|---|---|
| = (int)$branch['branch_sort_order'] ?> | = builderEscape($branchLabel) ?> | = builderEscape($branch['name']) ?> | = builderEscape($branch['trigger_type']) ?> | = builderEscape($branch['status']) ?> |
= builderEscape( $gameplayEffectDescription ) ?>
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.
Required. When these conditions become true, this Ending becomes terminal. Higher Priority wins if more than one Ending becomes true at the same time.
Add at least one condition.
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.
= builderEscape( $gameplayDescription ) ?>
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.
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.
= htmlspecialchars($intro, ENT_QUOTES, 'UTF-8') ?>
| 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.
|
||||||
> = htmlspecialchars( $actionParentContext ) ?>
What would you like to do?
No actions are currently available.
No actions are currently available.
Inventory is empty.
No player-visible traits or Statuses.
No player-visible traits or Statuses.
Nothing to display.
Item
= nl2br( htmlspecialchars( (string)$item['description'] ) ) ?>
Character
= nl2br( htmlspecialchars( (string)$character['description'] ) ) ?>
= htmlspecialchars( $applicableTraitsError ) ?>
Current Region: = htmlspecialchars( (string)$applicableRegion['name'] ) ?> (ID = (int)$applicableRegion['id'] ?>)
No current Region. The game does not currently resolve to a valid authored starting Region.
| Scope | Trait | Current Value | Type |
|---|---|---|---|
| = htmlspecialchars( (string)$row['scope'] ) ?> | = htmlspecialchars( (string)$row['name'] ) ?> | = htmlspecialchars( (string)$displayValue ) ?> | = htmlspecialchars( (string)$row['type'] ) ?> |
No active Traits are currently applicable.
------------------------------------------------------------------------------ FILE: play/includes/author-mode.php ------------------------------------------------------------------------------= htmlspecialchars( $currentRegionError ) ?>
No active Region is available for this game.
= htmlspecialchars( (string)$region['name'] ) ?> — = htmlspecialchars( (string)$region['navigation_type'] ) ?>
Region ID: = $regionId ?> | Position: = htmlspecialchars( (string)$position ) ?>
This Region has no active Traits.
| Trait | Current Value | Type |
|---|---|---|
| = htmlspecialchars( $traitName ) ?> | = htmlspecialchars( $displayValue ) ?> | = htmlspecialchars( (string)( $trait['type'] ?? '' ) ) ?> |
The End
You've arrived at
What do you do?
$branch ): ?>Enter or Space
= htmlspecialchars($sceneError) ?>
No current Region is available.
= nl2br( htmlspecialchars( (string)$sceneLocation['description'] ) ) ?>
= htmlspecialchars( (string)$sceneRegion['name'] ) ?>
= nl2br( htmlspecialchars( (string)$sceneRegion['description'] ) ) ?>
= htmlspecialchars( $tickPanelError ) ?>
Persisted tick: = (int)$tickRuntime['tick'] ?>
Runtime status: Paused
Player: = htmlspecialchars( $gameUser['display_name'] ?: $gameUser['username'] ) ?>
995 | min="0" 1001 | Each slot creates one built-in player character, requires a name at game start, and receives its own copy of every Player Trait. Use 0 for games without named player characters. 1037 | 'wayfarer_starting_item_quantity_targets' 1038 | ] = $startingChoiceItemTargets; 1042 | $traits, 1043 | $playerTraits, 1048 | $gameplayRuntimeTargets 1053 | 'wayfarer_starting_item_quantity_targets' 1061 | data-game-panel="traits" 1065 |
1079 | wayfarerRenderTraitEditor(
1080 | $traits,
1082 | 'field_name' => 'traits',
1083 | 'editor_id' => 'game-traits',
1086 | 'Game Traits have one runtime value for the whole game. ' .
1095 |
1099 | Define a trait once here and Wayfarer gives every built-in player
1101 | Character Slots plus a Health trait creates five separate Health values.
1105 | wayfarerRenderTraitEditor(
1106 | $playerTraits,
1108 | 'field_name' => 'player_traits',
1109 | 'editor_id' => 'player-traits',
1112 | 'Player Traits are instantiated once per named player slot. ' .
1113 | 'They are separate from Character Entity traits used by NPCs ' .
1131 |
1136 |
1141 | $gameplayGameTraitTargets =
1142 | gameplayNumericTraitTargets(
1143 | $traits,
1147 | $gameplayScopeTraitTargets =
1162 | $gameplayTargetCatalog =
1163 | $gameplayRuntimeTargets;
1165 | $gameplayConditionTargets =
1166 | $gameplayRuntimeTargets;
1169 | 'These effects are applied on every gameplay tick before the ' .
1170 | 'current Region Tick Effects.';
1176 | '/../../includes/gameplay/tick-effects-editor.php';
1187 |
1293 | Endings are terminal game states. After any runtime state change,
1294 | Wayfarer checks these conditions in priority order.
1302 | $traits
1309 | Player Traits
1098 |