============================================================================== 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']; ?> <?= builderEscape($game['name']) ?> - Wayfarer Builder

Wayfarer Builder

------------------------------------------------------------------------------ FILE: admin/edit.php ------------------------------------------------------------------------------ [FILE NOT FOUND] ------------------------------------------------------------------------------ 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 = []; $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: 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: config/database.php ------------------------------------------------------------------------------ PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false ] ); } catch (PDOException $e) { die('Database connection failed: ' . $e->getMessage()); } ------------------------------------------------------------------------------ FILE: includes/builder/collection-table.php ------------------------------------------------------------------------------

No have been created yet.

$column): ?> $column): ?>
------------------------------------------------------------------------------ FILE: includes/builder/collection.php ------------------------------------------------------------------------------ prepare($sql); $stmt->execute([$gameId]); $records = $stmt->fetchAll(); // ------------------------------------------------------------ // BUILD COLLECTION DISPLAY VALUES // ------------------------------------------------------------ if (!empty($listColumns)) { // Preload relationship labels so the collection does not issue one // query per row/cell. $relationshipLabelMaps = []; foreach ($relationships as $relationshipName => $relationship) { $options = getRelationshipOptions( $pdo, $relationship, $gameId ); foreach ($options as $option) { $relationshipLabelMaps[$relationshipName][ (string)$option['option_value'] ] = $option['option_label']; } } foreach ($records as &$item) { $item['_list'] = []; $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'; if ($source === 'field') { $fieldName = $column['field'] ?? $columnName; $item['_list'][$columnName] = $item[$fieldName] ?? ''; } elseif ($source === 'config') { $configField = $column['field'] ?? $columnName; $item['_list'][$columnName] = $itemConfig[$configField] ?? ''; } elseif ($source === 'relationship') { $relationshipName = $column['relationship'] ?? ''; $item['_list'][$columnName] = ''; if ( $relationshipName !== '' && isset($relationships[$relationshipName]) ) { $relationshipId = $item[$relationshipName] ?? null; if ($relationshipId !== null && $relationshipId !== '') { $item['_list'][$columnName] = $relationshipLabelMaps[$relationshipName][ (string)$relationshipId ] ?? ''; } } } } } unset($item); } // ------------------------------------------------------------ // RELATIONSHIP OPTIONS // ------------------------------------------------------------ $relationshipOptions = []; foreach ($relationships as $fieldName => $relationship) { $relationshipOptions[$fieldName] = getRelationshipOptions( $pdo, $relationship, $gameId ); } // ------------------------------------------------------------ // 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']; } } return [ 'records' => $records, 'relationshipOptions' => $relationshipOptions, 'conditionData' => $conditionData, ]; } ------------------------------------------------------------------------------ FILE: includes/builder/config-field.php ------------------------------------------------------------------------------

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

------------------------------------------------------------------------------ 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'] ?? ''); ?>
$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: includes/builder/field.php ------------------------------------------------------------------------------

------------------------------------------------------------------------------ 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. */ ?>

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. */ 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'))); ?>
>

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: 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: 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/functions.php ------------------------------------------------------------------------------ [FILE NOT FOUND] ------------------------------------------------------------------------------ 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', '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: 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.

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 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: 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: 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/runtime.php ------------------------------------------------------------------------------ [FILE NOT FOUND] ------------------------------------------------------------------------------ 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'] ?? '' ); $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: 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/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 = []; 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: 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, '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: 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: play/includes/player-state.php ------------------------------------------------------------------------------ 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'] ); } $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 ------------------------------------------------------------------------------ $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: 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; $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: 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: 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: 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:



============================================================================== DATABASE SCHEMA ============================================================================== ------------------------------------------------------------------------------ TABLE: wf_entities ------------------------------------------------------------------------------ CREATE TABLE `wf_entities` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `game_id` int(10) unsigned NOT NULL, `entity_type` varchar(30) NOT NULL DEFAULT 'item', `name` varchar(150) NOT NULL, `description` text DEFAULT NULL, `config` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`config`)), `status` varchar(20) NOT NULL DEFAULT 'draft', `created_at` timestamp NOT NULL DEFAULT current_timestamp(), `updated_at` timestamp NOT NULL DEFAULT current_timestamp(), PRIMARY KEY (`id`), CONSTRAINT `fk_wf_entities_game` FOREIGN KEY (`game_id`) REFERENCES `wf_games` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; COLUMNS: {"Field":"id","Type":"int(10) unsigned","Null":"NO","Key":"PRI","Default":null,"Extra":"auto_increment"} {"Field":"game_id","Type":"int(10) unsigned","Null":"NO","Key":"","Default":null,"Extra":""} {"Field":"entity_type","Type":"varchar(30)","Null":"NO","Key":"","Default":"item","Extra":""} {"Field":"name","Type":"varchar(150)","Null":"NO","Key":"","Default":null,"Extra":""} {"Field":"description","Type":"text","Null":"YES","Key":"","Default":null,"Extra":""} {"Field":"config","Type":"longtext","Null":"YES","Key":"","Default":null,"Extra":""} {"Field":"status","Type":"varchar(20)","Null":"NO","Key":"","Default":"draft","Extra":""} {"Field":"created_at","Type":"timestamp","Null":"NO","Key":"","Default":"current_timestamp()","Extra":""} {"Field":"updated_at","Type":"timestamp","Null":"NO","Key":"","Default":"current_timestamp()","Extra":""} ------------------------------------------------------------------------------ TABLE: wf_game_users ------------------------------------------------------------------------------ CREATE TABLE `wf_game_users` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `game_id` int(10) unsigned NOT NULL, `user_id` int(10) unsigned NOT NULL, `role` varchar(20) NOT NULL DEFAULT 'player', `state` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`state`)), `created_at` timestamp NOT NULL DEFAULT current_timestamp(), `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), PRIMARY KEY (`id`), UNIQUE KEY `uq_wf_game_user` (`game_id`,`user_id`), KEY `fk_wf_game_users_user` (`user_id`), CONSTRAINT `fk_wf_game_users_game` FOREIGN KEY (`game_id`) REFERENCES `wf_games` (`id`) ON DELETE CASCADE, CONSTRAINT `fk_wf_game_users_user` FOREIGN KEY (`user_id`) REFERENCES `wf_users` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; COLUMNS: {"Field":"id","Type":"int(10) unsigned","Null":"NO","Key":"PRI","Default":null,"Extra":"auto_increment"} {"Field":"game_id","Type":"int(10) unsigned","Null":"NO","Key":"MUL","Default":null,"Extra":""} {"Field":"user_id","Type":"int(10) unsigned","Null":"NO","Key":"MUL","Default":null,"Extra":""} {"Field":"role","Type":"varchar(20)","Null":"NO","Key":"","Default":"player","Extra":""} {"Field":"state","Type":"longtext","Null":"YES","Key":"","Default":null,"Extra":""} {"Field":"created_at","Type":"timestamp","Null":"NO","Key":"","Default":"current_timestamp()","Extra":""} {"Field":"updated_at","Type":"timestamp","Null":"NO","Key":"","Default":"current_timestamp()","Extra":"on update current_timestamp()"} ------------------------------------------------------------------------------ TABLE: wf_games ------------------------------------------------------------------------------ CREATE TABLE `wf_games` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `uuid` varchar(36) NOT NULL, `name` varchar(150) NOT NULL, `slug` varchar(100) NOT NULL, `description` text DEFAULT NULL, `instructions` text DEFAULT NULL, `player_slots` int(10) unsigned NOT NULL DEFAULT 0, `config` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`config`)), `status` varchar(20) NOT NULL DEFAULT 'draft', `created_at` timestamp NOT NULL DEFAULT current_timestamp(), `updated_at` timestamp NOT NULL DEFAULT current_timestamp(), PRIMARY KEY (`id`), UNIQUE KEY `uq_wf_games_uuid` (`uuid`), UNIQUE KEY `uq_wf_games_slug` (`slug`) ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; COLUMNS: {"Field":"id","Type":"int(10) unsigned","Null":"NO","Key":"PRI","Default":null,"Extra":"auto_increment"} {"Field":"uuid","Type":"varchar(36)","Null":"NO","Key":"UNI","Default":null,"Extra":""} {"Field":"name","Type":"varchar(150)","Null":"NO","Key":"","Default":null,"Extra":""} {"Field":"slug","Type":"varchar(100)","Null":"NO","Key":"UNI","Default":null,"Extra":""} {"Field":"description","Type":"text","Null":"YES","Key":"","Default":null,"Extra":""} {"Field":"instructions","Type":"text","Null":"YES","Key":"","Default":null,"Extra":""} {"Field":"player_slots","Type":"int(10) unsigned","Null":"NO","Key":"","Default":"0","Extra":""} {"Field":"config","Type":"longtext","Null":"YES","Key":"","Default":null,"Extra":""} {"Field":"status","Type":"varchar(20)","Null":"NO","Key":"","Default":"draft","Extra":""} {"Field":"created_at","Type":"timestamp","Null":"NO","Key":"","Default":"current_timestamp()","Extra":""} {"Field":"updated_at","Type":"timestamp","Null":"NO","Key":"","Default":"current_timestamp()","Extra":""} ------------------------------------------------------------------------------ TABLE: wf_locations ------------------------------------------------------------------------------ CREATE TABLE `wf_locations` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `region_id` int(10) unsigned NOT NULL, `name` varchar(150) NOT NULL, `description` mediumtext DEFAULT NULL, `config` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`config`)), `status` varchar(20) NOT NULL DEFAULT 'draft', `created_at` timestamp NOT NULL DEFAULT current_timestamp(), `updated_at` timestamp NOT NULL DEFAULT current_timestamp(), PRIMARY KEY (`id`), KEY `idx_wf_locations_region` (`region_id`), CONSTRAINT `fk_wf_locations_region` FOREIGN KEY (`region_id`) REFERENCES `wf_regions` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB AUTO_INCREMENT=20 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; COLUMNS: {"Field":"id","Type":"int(10) unsigned","Null":"NO","Key":"PRI","Default":null,"Extra":"auto_increment"} {"Field":"region_id","Type":"int(10) unsigned","Null":"NO","Key":"MUL","Default":null,"Extra":""} {"Field":"name","Type":"varchar(150)","Null":"NO","Key":"","Default":null,"Extra":""} {"Field":"description","Type":"mediumtext","Null":"YES","Key":"","Default":null,"Extra":""} {"Field":"config","Type":"longtext","Null":"YES","Key":"","Default":null,"Extra":""} {"Field":"status","Type":"varchar(20)","Null":"NO","Key":"","Default":"draft","Extra":""} {"Field":"created_at","Type":"timestamp","Null":"NO","Key":"","Default":"current_timestamp()","Extra":""} {"Field":"updated_at","Type":"timestamp","Null":"NO","Key":"","Default":"current_timestamp()","Extra":""} ------------------------------------------------------------------------------ TABLE: wf_media ------------------------------------------------------------------------------ CREATE TABLE `wf_media` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `game_id` bigint(20) unsigned NOT NULL, `media_type` varchar(20) NOT NULL, `name` varchar(255) NOT NULL, `original_filename` varchar(255) NOT NULL, `stored_filename` varchar(255) NOT NULL, `relative_path` varchar(500) NOT NULL, `mime_type` varchar(100) NOT NULL, `file_size` bigint(20) unsigned NOT NULL DEFAULT 0, `created_at` datetime NOT NULL DEFAULT current_timestamp(), PRIMARY KEY (`id`), UNIQUE KEY `uq_wf_media_path` (`relative_path`), KEY `idx_wf_media_game_type` (`game_id`,`media_type`), KEY `idx_wf_media_game_name` (`game_id`,`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; COLUMNS: {"Field":"id","Type":"bigint(20) unsigned","Null":"NO","Key":"PRI","Default":null,"Extra":"auto_increment"} {"Field":"game_id","Type":"bigint(20) unsigned","Null":"NO","Key":"MUL","Default":null,"Extra":""} {"Field":"media_type","Type":"varchar(20)","Null":"NO","Key":"","Default":null,"Extra":""} {"Field":"name","Type":"varchar(255)","Null":"NO","Key":"","Default":null,"Extra":""} {"Field":"original_filename","Type":"varchar(255)","Null":"NO","Key":"","Default":null,"Extra":""} {"Field":"stored_filename","Type":"varchar(255)","Null":"NO","Key":"","Default":null,"Extra":""} {"Field":"relative_path","Type":"varchar(500)","Null":"NO","Key":"UNI","Default":null,"Extra":""} {"Field":"mime_type","Type":"varchar(100)","Null":"NO","Key":"","Default":null,"Extra":""} {"Field":"file_size","Type":"bigint(20) unsigned","Null":"NO","Key":"","Default":"0","Extra":""} {"Field":"created_at","Type":"datetime","Null":"NO","Key":"","Default":"current_timestamp()","Extra":""} ------------------------------------------------------------------------------ TABLE: wf_media_links ------------------------------------------------------------------------------ CREATE TABLE `wf_media_links` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `media_id` bigint(20) unsigned NOT NULL, `object_type` varchar(64) NOT NULL, `object_id` bigint(20) unsigned NOT NULL, `slot` varchar(64) NOT NULL, `created_at` datetime NOT NULL DEFAULT current_timestamp(), PRIMARY KEY (`id`), UNIQUE KEY `uq_wf_media_object_slot` (`object_type`,`object_id`,`slot`), KEY `idx_wf_media_links_media` (`media_id`), KEY `idx_wf_media_links_object` (`object_type`,`object_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; COLUMNS: {"Field":"id","Type":"bigint(20) unsigned","Null":"NO","Key":"PRI","Default":null,"Extra":"auto_increment"} {"Field":"media_id","Type":"bigint(20) unsigned","Null":"NO","Key":"MUL","Default":null,"Extra":""} {"Field":"object_type","Type":"varchar(64)","Null":"NO","Key":"MUL","Default":null,"Extra":""} {"Field":"object_id","Type":"bigint(20) unsigned","Null":"NO","Key":"","Default":null,"Extra":""} {"Field":"slot","Type":"varchar(64)","Null":"NO","Key":"","Default":null,"Extra":""} {"Field":"created_at","Type":"datetime","Null":"NO","Key":"","Default":"current_timestamp()","Extra":""} ------------------------------------------------------------------------------ TABLE: wf_regions ------------------------------------------------------------------------------ CREATE TABLE `wf_regions` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `game_id` int(10) unsigned NOT NULL, `name` varchar(150) NOT NULL, `description` text DEFAULT NULL, `navigation_type` varchar(30) NOT NULL, `config` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`config`)), `status` varchar(20) NOT NULL DEFAULT 'draft', `created_at` timestamp NOT NULL DEFAULT current_timestamp(), `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), PRIMARY KEY (`id`), KEY `idx_wf_regions_game` (`game_id`) ) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci; COLUMNS: {"Field":"id","Type":"int(10) unsigned","Null":"NO","Key":"PRI","Default":null,"Extra":"auto_increment"} {"Field":"game_id","Type":"int(10) unsigned","Null":"NO","Key":"MUL","Default":null,"Extra":""} {"Field":"name","Type":"varchar(150)","Null":"NO","Key":"","Default":null,"Extra":""} {"Field":"description","Type":"text","Null":"YES","Key":"","Default":null,"Extra":""} {"Field":"navigation_type","Type":"varchar(30)","Null":"NO","Key":"","Default":null,"Extra":""} {"Field":"config","Type":"longtext","Null":"YES","Key":"","Default":null,"Extra":""} {"Field":"status","Type":"varchar(20)","Null":"NO","Key":"","Default":"draft","Extra":""} {"Field":"created_at","Type":"timestamp","Null":"NO","Key":"","Default":"current_timestamp()","Extra":""} {"Field":"updated_at","Type":"timestamp","Null":"NO","Key":"","Default":"current_timestamp()","Extra":"on update current_timestamp()"} ------------------------------------------------------------------------------ TABLE: wf_statuses ------------------------------------------------------------------------------ CREATE TABLE `wf_statuses` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `game_id` int(10) unsigned NOT NULL, `name` varchar(190) NOT NULL, `description` text DEFAULT NULL, `config` longtext DEFAULT NULL, `status` varchar(20) NOT NULL DEFAULT 'draft', `created_at` timestamp NOT NULL DEFAULT current_timestamp(), `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), PRIMARY KEY (`id`), KEY `idx_wf_statuses_game` (`game_id`), KEY `idx_wf_statuses_game_status` (`game_id`,`status`) ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; COLUMNS: {"Field":"id","Type":"int(10) unsigned","Null":"NO","Key":"PRI","Default":null,"Extra":"auto_increment"} {"Field":"game_id","Type":"int(10) unsigned","Null":"NO","Key":"MUL","Default":null,"Extra":""} {"Field":"name","Type":"varchar(190)","Null":"NO","Key":"","Default":null,"Extra":""} {"Field":"description","Type":"text","Null":"YES","Key":"","Default":null,"Extra":""} {"Field":"config","Type":"longtext","Null":"YES","Key":"","Default":null,"Extra":""} {"Field":"status","Type":"varchar(20)","Null":"NO","Key":"","Default":"draft","Extra":""} {"Field":"created_at","Type":"timestamp","Null":"NO","Key":"","Default":"current_timestamp()","Extra":""} {"Field":"updated_at","Type":"timestamp","Null":"NO","Key":"","Default":"current_timestamp()","Extra":"on update current_timestamp()"} ------------------------------------------------------------------------------ TABLE: wf_storylet_branches ------------------------------------------------------------------------------ CREATE TABLE `wf_storylet_branches` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `from_storylet_id` int(10) unsigned NOT NULL, `to_storylet_id` int(10) unsigned NOT NULL, `label` varchar(255) DEFAULT NULL, `sort_order` int(10) unsigned NOT NULL DEFAULT 0, `created_at` timestamp NOT NULL DEFAULT current_timestamp(), PRIMARY KEY (`id`), KEY `idx_wf_storylet_branches_from` (`from_storylet_id`), KEY `idx_wf_storylet_branches_to` (`to_storylet_id`) ) ENGINE=InnoDB DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci; COLUMNS: {"Field":"id","Type":"int(10) unsigned","Null":"NO","Key":"PRI","Default":null,"Extra":"auto_increment"} {"Field":"from_storylet_id","Type":"int(10) unsigned","Null":"NO","Key":"MUL","Default":null,"Extra":""} {"Field":"to_storylet_id","Type":"int(10) unsigned","Null":"NO","Key":"MUL","Default":null,"Extra":""} {"Field":"label","Type":"varchar(255)","Null":"YES","Key":"","Default":null,"Extra":""} {"Field":"sort_order","Type":"int(10) unsigned","Null":"NO","Key":"","Default":"0","Extra":""} {"Field":"created_at","Type":"timestamp","Null":"NO","Key":"","Default":"current_timestamp()","Extra":""} ------------------------------------------------------------------------------ TABLE: wf_storylets ------------------------------------------------------------------------------ CREATE TABLE `wf_storylets` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `uuid` varchar(36) NOT NULL, `game_id` int(10) unsigned NOT NULL, `name` varchar(200) NOT NULL, `storylet_key` varchar(100) NOT NULL, `description` mediumtext DEFAULT NULL, `conditions` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`conditions`)), `effects` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`effects`)), `max_occurrences` int(10) unsigned DEFAULT NULL, `trigger_type` varchar(30) NOT NULL DEFAULT 'manual', `status` varchar(20) NOT NULL DEFAULT 'draft', `created_at` timestamp NOT NULL DEFAULT current_timestamp(), `updated_at` timestamp NOT NULL DEFAULT current_timestamp(), `config` longtext DEFAULT NULL, `parent_storylet_id` int(11) DEFAULT NULL, `choice_label` varchar(255) DEFAULT NULL, `choice_sort_order` int(11) NOT NULL DEFAULT 0, `branch_label` varchar(255) DEFAULT NULL, `branch_sort_order` int(11) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `uq_wf_storylets_uuid` (`uuid`), UNIQUE KEY `uq_wf_storylet_key` (`game_id`,`storylet_key`), KEY `idx_wf_storylets_parent_storylet_id` (`parent_storylet_id`), CONSTRAINT `fk_wf_storylets_game` FOREIGN KEY (`game_id`) REFERENCES `wf_games` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB AUTO_INCREMENT=47 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; COLUMNS: {"Field":"id","Type":"int(10) unsigned","Null":"NO","Key":"PRI","Default":null,"Extra":"auto_increment"} {"Field":"uuid","Type":"varchar(36)","Null":"NO","Key":"UNI","Default":null,"Extra":""} {"Field":"game_id","Type":"int(10) unsigned","Null":"NO","Key":"MUL","Default":null,"Extra":""} {"Field":"name","Type":"varchar(200)","Null":"NO","Key":"","Default":null,"Extra":""} {"Field":"storylet_key","Type":"varchar(100)","Null":"NO","Key":"","Default":null,"Extra":""} {"Field":"description","Type":"mediumtext","Null":"YES","Key":"","Default":null,"Extra":""} {"Field":"conditions","Type":"longtext","Null":"YES","Key":"","Default":null,"Extra":""} {"Field":"effects","Type":"longtext","Null":"YES","Key":"","Default":null,"Extra":""} {"Field":"max_occurrences","Type":"int(10) unsigned","Null":"YES","Key":"","Default":null,"Extra":""} {"Field":"trigger_type","Type":"varchar(30)","Null":"NO","Key":"","Default":"manual","Extra":""} {"Field":"status","Type":"varchar(20)","Null":"NO","Key":"","Default":"draft","Extra":""} {"Field":"created_at","Type":"timestamp","Null":"NO","Key":"","Default":"current_timestamp()","Extra":""} {"Field":"updated_at","Type":"timestamp","Null":"NO","Key":"","Default":"current_timestamp()","Extra":""} {"Field":"config","Type":"longtext","Null":"YES","Key":"","Default":null,"Extra":""} {"Field":"parent_storylet_id","Type":"int(11)","Null":"YES","Key":"MUL","Default":null,"Extra":""} {"Field":"choice_label","Type":"varchar(255)","Null":"YES","Key":"","Default":null,"Extra":""} {"Field":"choice_sort_order","Type":"int(11)","Null":"NO","Key":"","Default":"0","Extra":""} {"Field":"branch_label","Type":"varchar(255)","Null":"YES","Key":"","Default":null,"Extra":""} {"Field":"branch_sort_order","Type":"int(11)","Null":"NO","Key":"","Default":"0","Extra":""} ------------------------------------------------------------------------------ TABLE: wf_users ------------------------------------------------------------------------------ CREATE TABLE `wf_users` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `uuid` varchar(36) NOT NULL, `username` varchar(100) NOT NULL, `email` varchar(255) DEFAULT NULL, `password_hash` varchar(255) NOT NULL, `display_name` varchar(150) DEFAULT NULL, `role` varchar(20) NOT NULL DEFAULT 'player', `status` varchar(20) NOT NULL DEFAULT 'active', `created_at` timestamp NOT NULL DEFAULT current_timestamp(), `updated_at` timestamp NOT NULL DEFAULT current_timestamp(), `last_login_at` timestamp NULL DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uq_wf_users_uuid` (`uuid`), UNIQUE KEY `uq_wf_users_username` (`username`), UNIQUE KEY `uq_wf_users_email` (`email`) ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; COLUMNS: {"Field":"id","Type":"int(10) unsigned","Null":"NO","Key":"PRI","Default":null,"Extra":"auto_increment"} {"Field":"uuid","Type":"varchar(36)","Null":"NO","Key":"UNI","Default":null,"Extra":""} {"Field":"username","Type":"varchar(100)","Null":"NO","Key":"UNI","Default":null,"Extra":""} {"Field":"email","Type":"varchar(255)","Null":"YES","Key":"UNI","Default":null,"Extra":""} {"Field":"password_hash","Type":"varchar(255)","Null":"NO","Key":"","Default":null,"Extra":""} {"Field":"display_name","Type":"varchar(150)","Null":"YES","Key":"","Default":null,"Extra":""} {"Field":"role","Type":"varchar(20)","Null":"NO","Key":"","Default":"player","Extra":""} {"Field":"status","Type":"varchar(20)","Null":"NO","Key":"","Default":"active","Extra":""} {"Field":"created_at","Type":"timestamp","Null":"NO","Key":"","Default":"current_timestamp()","Extra":""} {"Field":"updated_at","Type":"timestamp","Null":"NO","Key":"","Default":"current_timestamp()","Extra":""} {"Field":"last_login_at","Type":"timestamp","Null":"YES","Key":"","Default":null,"Extra":""} ============================================================================== GAME 1 AUTHORING DATA / CURRENT JSON SHAPES ============================================================================== ------------------------------------------------------------------------------ DATA: wf_games ------------------------------------------------------------------------------ { "id": 1, "uuid": "5295c93f6b289525aa2424718d94907c", "name": "Stormgate", "slug": "stormgate", "description": "A short Wayfarer test game about reaching the city of Stormgate before an approaching storm closes the road.", "instructions": "Reach Stormgate with at least 14 Haper Flowers before the Storm Clock reaches 0.\r\n\r\nTravel consumes 1 Water and 1 Storm Clock each tick. At major stops you may continue traveling immediately or choose an available Storylet Action. Your starting pack changes which route is easiest, but neither choice locks you into a single solution.", "player_slots": 4, "config": "{\"traits\":[{\"id\":\"storm_clock\",\"name\":\"Storm Clock\",\"type\":\"number\",\"active\":true,\"notes\":\"Ticks remaining before the storm overtakes the road.\",\"display\":{\"surfaces\":[],\"status_mode\":\"number\"},\"min\":0,\"max\":24,\"initial\":0}],\"starting_region_id\":5,\"setup\":{\"choices\":[{\"id\":\"choice_stormgate_pack\",\"prompt\":\"What did you pack for the final road to Stormgate?\",\"required\":true,\"options\":[{\"id\":\"option_full_waterskin\",\"label\":\"Full Waterskin\",\"description\":\"Begin with 8 additional Water. This makes trading at the tollhouse much easier.\",\"effects\":[{\"target_scope\":\"entity:9\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operation\":\"add\",\"value_mode\":\"fixed\",\"value\":8,\"target_system_key\":\"quantity\"}]},{\"id\":\"option_forager_notes\",\"label\":\"Forager's Notes\",\"description\":\"Begin with 3 Haper Flowers already collected, but no extra Water.\",\"effects\":[{\"target_scope\":\"entity:1\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operation\":\"add\",\"value_mode\":\"fixed\",\"value\":3,\"target_system_key\":\"quantity\"}]}]}]},\"player_traits\":[{\"id\":\"tr_40d91aae45dfb4da\",\"name\":\"Health\",\"type\":\"number\",\"active\":true,\"notes\":\"\",\"display\":{\"surfaces\":[\"status\"],\"status_mode\":\"bar\"},\"min\":0,\"max\":100,\"initial\":90},{\"id\":\"tr_c728198613565caf\",\"name\":\"Stamina\",\"type\":\"number\",\"active\":true,\"notes\":\"\",\"display\":{\"surfaces\":[\"status\"],\"status_mode\":\"bar\"},\"min\":0,\"max\":100,\"initial\":80}],\"endings\":[{\"id\":\"ending_stormgate_storm\",\"title\":\"Overtaken\",\"text\":\"The storm reaches the road before you can get safely inside Stormgate.\",\"image_media_id\":0,\"priority\":300,\"conditions\":{\"type\":\"group\",\"mode\":\"all\",\"children\":[{\"type\":\"condition\",\"scope\":\"game\",\"target_type\":\"trait\",\"target_id\":\"storm_clock\",\"operator\":\"greater_than_or_equal\",\"value\":24}]}},{\"id\":\"ending_stormgate_open\",\"title\":\"Stormgate Opens\",\"text\":\"You reach Stormgate with the required fourteen Haper Flowers. The wardens take the bundle and pull you through as the first hard rain strikes the gate.\",\"image_media_id\":0,\"priority\":200,\"conditions\":{\"type\":\"group\",\"mode\":\"all\",\"children\":[{\"type\":\"condition\",\"scope\":\"navigation\",\"target_type\":\"position\",\"target_id\":\"position\",\"operator\":\"greater_than_or_equal\",\"value\":200},{\"type\":\"condition\",\"scope\":\"entity:1\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operator\":\"greater_than_or_equal\",\"value\":14},{\"type\":\"condition\",\"scope\":\"game\",\"target_type\":\"trait\",\"target_id\":\"storm_clock\",\"operator\":\"less_than\",\"value\":24}]}},{\"id\":\"ending_stormgate_refused\",\"title\":\"Refused at the Gate\",\"text\":\"You reach Stormgate in time, but you do not have fourteen Haper Flowers. The wardens refuse to reopen the gate for you.\",\"image_media_id\":0,\"priority\":100,\"conditions\":{\"type\":\"group\",\"mode\":\"all\",\"children\":[{\"type\":\"condition\",\"scope\":\"navigation\",\"target_type\":\"position\",\"target_id\":\"position\",\"operator\":\"greater_than_or_equal\",\"value\":200},{\"type\":\"condition\",\"scope\":\"entity:1\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operator\":\"less_than\",\"value\":14},{\"type\":\"condition\",\"scope\":\"game\",\"target_type\":\"trait\",\"target_id\":\"storm_clock\",\"operator\":\"less_than\",\"value\":24}]}}],\"tick_effects\":[{\"target_scope\":\"game\",\"target_type\":\"trait\",\"target_id\":\"storm_clock\",\"operation\":\"add\",\"value\":1,\"branches\":[{\"conditions\":null,\"operation\":\"add\",\"value_mode\":\"fixed\",\"value\":1}]},{\"target_scope\":\"entity:9\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operation\":\"subtract\",\"value\":1,\"branches\":[{\"conditions\":null,\"operation\":\"subtract\",\"value_mode\":\"fixed\",\"value\":1}]}],\"time\":{\"mode\":\"hours\",\"starting_day\":1,\"starting_hour\":8,\"ticks_per_hour\":1,\"hours_per_day\":24}}", "status": "active", "created_at": "2026-09-21 12:28:28", "updated_at": "2026-09-21 12:28:28" } ------------------------------------------------------------------------------ DATA: wf_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_mode\":\"fixed\",\"value\":10}]}],\"distance_label\":\"km\",\"hud\":{\"enabled\":true,\"components\":[{\"id\":\"hud_2a7f0d6cff6d\",\"type\":\"progress_bar\",\"source_scope\":\"game\",\"source_id\":\"storm_clock\",\"label\":\"\",\"show_value\":false}]}}", "status": "draft", "created_at": "2026-09-21 13:01:57", "updated_at": "2026-09-25 22:49:30" } { "id": 5, "game_id": 1, "name": "Stormgate Road", "description": "The last exposed road to Stormgate, running through abandoned farms and storm-bent orchards.", "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_mode\":\"fixed\",\"value\":10}]}],\"distance_label\":\"km\",\"hud\":{\"enabled\":true,\"components\":[{\"id\":\"hud_a5ede4328c65\",\"type\":\"progress_bar\",\"source_scope\":\"game\",\"source_id\":\"storm_clock\",\"label\":\"\",\"show_value\":false}]}}", "status": "active", "created_at": "2026-09-25 18:22:38", "updated_at": "2026-09-25 22:50:40" } ------------------------------------------------------------------------------ DATA: wf_locations ------------------------------------------------------------------------------ [No game_id column; structural schema captured above.] ------------------------------------------------------------------------------ DATA: wf_entities ------------------------------------------------------------------------------ { "id": 1, "game_id": 1, "entity_type": "item", "name": "Haper Flower", "description": "A hardy medicinal flower accepted by Stormgate as an emergency-entry toll.", "config": "{\"traits\":[],\"system\":{\"quantity\":{\"initial\":0,\"max\":null},\"inventory_visibility\":\"once_discovered\"}}", "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 young woman.", "config": "{\"traits\":[{\"id\":\"tr_9ee02280272c3c5c\",\"name\":\"Health\",\"type\":\"number\",\"active\":true,\"notes\":\"\",\"display\":{\"surfaces\":[\"status\"],\"status_mode\":\"bar\"},\"min\":0,\"max\":100,\"initial\":50},{\"id\":\"tr_bae46c6a52ea591d\",\"name\":\"Lucky\",\"type\":\"number\",\"active\":true,\"notes\":\"\",\"display\":{\"surfaces\":[\"status\"],\"status_mode\":\"number\"},\"min\":0,\"max\":100,\"initial\":0}],\"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": "inactive", "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": "inactive", "created_at": "2026-09-25 12:50:59", "updated_at": "2026-09-25 12:50:59" } { "id": 9, "game_id": 1, "entity_type": "item", "name": "Water", "description": "Clean water carried for the road. One unit is consumed each travel tick.", "config": "{\"traits\":[],\"system\":{\"quantity\":{\"initial\":16,\"max\":null},\"inventory_visibility\":\"always\"}}", "status": "active", "created_at": "2026-09-25 18:22:38", "updated_at": "2026-09-25 18:22:38" } { "id": 10, "game_id": 1, "entity_type": "item", "name": "Old Coin", "description": "A worn silver coin of uncertain value.", "config": "{\"traits\":[],\"system\":{\"quantity\":{\"initial\":0,\"max\":null},\"inventory_visibility\":\"once_discovered\"}}", "status": "active", "created_at": "2026-09-25 18:22:38", "updated_at": "2026-09-25 18:22:38" } ------------------------------------------------------------------------------ DATA: wf_storylets ------------------------------------------------------------------------------ { "id": 5, "uuid": "b0700f76c21f4a5f00cdd45880bd2c55", "game_id": 1, "name": "Search the Orange Grove", "storylet_key": "v60-search-the-orange-grove-e99932b8", "description": "The grove could hold enough Haper Flower to matter, but the storm has made the rows difficult to cross. You can search carefully along the old wall or push into the denser interior.", "conditions": null, "effects": null, "max_occurrences": 1, "trigger_type": "manual", "status": "active", "created_at": "2026-09-25 18:22:38", "updated_at": "2026-09-25 18:22:38", "config": "{\"priority\":20,\"tick_cost\":0,\"chance_percent\":0,\"interrupts_travel\":\"0\",\"conditions\":{\"type\":\"group\",\"mode\":\"all\",\"children\":[{\"type\":\"condition\",\"scope\":\"navigation\",\"target_type\":\"location\",\"target_id\":\"5\",\"operator\":\"equals\",\"value\":0}]}}", "parent_storylet_id": null, "choice_label": null, "choice_sort_order": 0, "branch_label": "", "branch_sort_order": 0 } { "id": 6, "uuid": "8f2bbc1f57a0824b3fb0fa7f18667824", "game_id": 1, "name": "Grove — Search the Old Wall", "storylet_key": "v60-grove-search-the-old-wall-da1aaaf5", "description": "You work along the collapsed wall where the ground is protected from the wind. There are flowers here, and beneath one stone you find an old silver coin.", "conditions": null, "effects": null, "max_occurrences": 1, "trigger_type": "manual", "status": "active", "created_at": "2026-09-25 18:22:38", "updated_at": "2026-09-25 18:22:38", "config": "{\"priority\":100,\"tick_cost\":0,\"chance_percent\":0,\"interrupts_travel\":\"0\",\"effects\":[{\"target_scope\":\"entity:1\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operation\":\"add\",\"branches\":[{\"conditions\":null,\"operation\":\"add\",\"value_mode\":\"random\",\"value_min\":5,\"value_max\":7}],\"target_system_key\":\"quantity\",\"value_mode\":\"random\",\"value_min\":5,\"value_max\":7},{\"target_scope\":\"entity:10\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operation\":\"add\",\"branches\":[{\"conditions\":null,\"operation\":\"add\",\"value_mode\":\"fixed\",\"value\":1}],\"target_system_key\":\"quantity\",\"value_mode\":\"fixed\",\"value\":1}]}", "parent_storylet_id": 5, "choice_label": null, "choice_sort_order": 0, "branch_label": "Search along the old wall", "branch_sort_order": 10 } { "id": 7, "uuid": "4c235539b15d77d4d787cb536de9ced5", "game_id": 1, "name": "Grove — Push Deeper", "storylet_key": "v60-grove-push-deeper-35b8c241", "description": "The interior is harder going, but the sheltered rows are thick with Haper Flower. You lose some water fighting through the fallen branches.", "conditions": null, "effects": null, "max_occurrences": 1, "trigger_type": "manual", "status": "active", "created_at": "2026-09-25 18:22:38", "updated_at": "2026-09-25 18:22:38", "config": "{\"priority\":100,\"tick_cost\":0,\"chance_percent\":0,\"interrupts_travel\":\"0\",\"effects\":[{\"target_scope\":\"entity:1\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operation\":\"add\",\"branches\":[{\"conditions\":null,\"operation\":\"add\",\"value_mode\":\"random\",\"value_min\":8,\"value_max\":11}],\"target_system_key\":\"quantity\",\"value_mode\":\"random\",\"value_min\":8,\"value_max\":11},{\"target_scope\":\"entity:9\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operation\":\"subtract\",\"branches\":[{\"conditions\":null,\"operation\":\"subtract\",\"value_mode\":\"fixed\",\"value\":3}],\"target_system_key\":\"quantity\",\"value_mode\":\"fixed\",\"value\":3}]}", "parent_storylet_id": 5, "choice_label": null, "choice_sort_order": 0, "branch_label": "Push deeper into the grove", "branch_sort_order": 20 } { "id": 8, "uuid": "529434092798a6882c5fa3bec99ac38a", "game_id": 1, "name": "Examine the Wayside Shrine", "storylet_key": "v60-examine-the-wayside-shrine-edfbe2fc", "description": "The shrine's bowl is empty, but faded carvings promise good fortune to travelers who leave something of value behind.", "conditions": null, "effects": null, "max_occurrences": 1, "trigger_type": "manual", "status": "active", "created_at": "2026-09-25 18:22:38", "updated_at": "2026-09-25 18:22:38", "config": "{\"priority\":30,\"tick_cost\":0,\"chance_percent\":0,\"interrupts_travel\":\"0\",\"conditions\":{\"type\":\"group\",\"mode\":\"all\",\"children\":[{\"type\":\"condition\",\"scope\":\"navigation\",\"target_type\":\"location\",\"target_id\":\"17\",\"operator\":\"equals\",\"value\":0}]}}", "parent_storylet_id": null, "choice_label": null, "choice_sort_order": 0, "branch_label": "", "branch_sort_order": 0 } { "id": 9, "uuid": "c0db2d050f73cfb766b15b1a083ef43a", "game_id": 1, "name": "Shrine — Leave the Old Coin", "storylet_key": "v60-shrine-leave-the-old-coin-c3154835", "description": "You leave the silver coin in the bowl. Behind the shrine you notice a patch of Haper Flower you somehow missed from the road.", "conditions": null, "effects": null, "max_occurrences": 1, "trigger_type": "manual", "status": "active", "created_at": "2026-09-25 18:22:38", "updated_at": "2026-09-25 18:22:38", "config": "{\"priority\":100,\"tick_cost\":0,\"chance_percent\":0,\"interrupts_travel\":\"0\",\"conditions\":{\"type\":\"condition\",\"scope\":\"entity:10\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operator\":\"greater_than_or_equal\",\"value\":1},\"effects\":[{\"target_scope\":\"entity:10\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operation\":\"subtract\",\"branches\":[{\"conditions\":null,\"operation\":\"subtract\",\"value_mode\":\"fixed\",\"value\":1}],\"target_system_key\":\"quantity\",\"value_mode\":\"fixed\",\"value\":1},{\"target_scope\":\"entity:1\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operation\":\"add\",\"branches\":[{\"conditions\":null,\"operation\":\"add\",\"value_mode\":\"fixed\",\"value\":4}],\"target_system_key\":\"quantity\",\"value_mode\":\"fixed\",\"value\":4}]}", "parent_storylet_id": 8, "choice_label": null, "choice_sort_order": 0, "branch_label": "Leave the old coin", "branch_sort_order": 10 } { "id": 10, "uuid": "3f03d4642a5555ce9db7fb1c36b63764", "game_id": 1, "name": "Shrine — Move On", "storylet_key": "v60-shrine-move-on-719b29ab", "description": "Whatever the shrine once promised, the road matters more. You leave it behind and keep Stormgate in sight.", "conditions": null, "effects": null, "max_occurrences": 1, "trigger_type": "manual", "status": "active", "created_at": "2026-09-25 18:22:38", "updated_at": "2026-09-25 18:22:38", "config": "{\"priority\":100,\"tick_cost\":0,\"chance_percent\":0,\"interrupts_travel\":\"0\"}", "parent_storylet_id": 8, "choice_label": null, "choice_sort_order": 0, "branch_label": "Leave the shrine alone", "branch_sort_order": 20 } { "id": 11, "uuid": "f160694477f185d503fc90a79aa01a61", "game_id": 1, "name": "Speak with Mara", "storylet_key": "v60-speak-with-mara-10f819b7", "description": "Mara has been gathering Haper Flower while waiting out the weather. She will trade a bundle for water, or she will travel with you and share a smaller handful if you would rather keep your supplies.", "conditions": null, "effects": null, "max_occurrences": 1, "trigger_type": "manual", "status": "active", "created_at": "2026-09-25 18:22:38", "updated_at": "2026-09-25 18:22:38", "config": "{\"priority\":40,\"tick_cost\":0,\"chance_percent\":0,\"interrupts_travel\":\"0\",\"conditions\":{\"type\":\"group\",\"mode\":\"all\",\"children\":[{\"type\":\"condition\",\"scope\":\"navigation\",\"target_type\":\"location\",\"target_id\":\"3\",\"operator\":\"equals\",\"value\":0}]}}", "parent_storylet_id": null, "choice_label": null, "choice_sort_order": 0, "branch_label": "", "branch_sort_order": 0 } { "id": 12, "uuid": "e1e10f57166671bab48aa85852a48da3", "game_id": 1, "name": "Mara — Trade Water", "storylet_key": "v60-mara-trade-water-7edc7f17", "description": "Six measures of water buy Mara's larger bundle. She stays at the tollhouse, but you leave with enough flowers to make the bargain worthwhile.", "conditions": null, "effects": null, "max_occurrences": 1, "trigger_type": "manual", "status": "active", "created_at": "2026-09-25 18:22:38", "updated_at": "2026-09-25 18:22:38", "config": "{\"priority\":100,\"tick_cost\":0,\"chance_percent\":0,\"interrupts_travel\":\"0\",\"conditions\":{\"type\":\"condition\",\"scope\":\"entity:9\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operator\":\"greater_than_or_equal\",\"value\":6},\"effects\":[{\"target_scope\":\"entity:9\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operation\":\"subtract\",\"branches\":[{\"conditions\":null,\"operation\":\"subtract\",\"value_mode\":\"fixed\",\"value\":6}],\"target_system_key\":\"quantity\",\"value_mode\":\"fixed\",\"value\":6},{\"target_scope\":\"entity:1\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operation\":\"add\",\"branches\":[{\"conditions\":null,\"operation\":\"add\",\"value_mode\":\"fixed\",\"value\":6}],\"target_system_key\":\"quantity\",\"value_mode\":\"fixed\",\"value\":6}]}", "parent_storylet_id": 11, "choice_label": null, "choice_sort_order": 0, "branch_label": "Trade 6 Water for 6 Haper Flowers", "branch_sort_order": 10 } { "id": 13, "uuid": "07ab5160489a68e46d88af9940d2397b", "game_id": 1, "name": "Mara — Travel Together", "storylet_key": "v60-mara-travel-together-267dce21", "description": "Mara shoulders her pack and falls into step beside you. She gives you the smaller bundle and says she knows the gate wardens well enough to keep you from wasting time once you arrive.", "conditions": null, "effects": null, "max_occurrences": 1, "trigger_type": "manual", "status": "active", "created_at": "2026-09-25 18:22:38", "updated_at": "2026-09-25 18:22:38", "config": "{\"priority\":100,\"tick_cost\":0,\"chance_percent\":0,\"interrupts_travel\":\"0\",\"conditions\":{\"type\":\"condition\",\"scope\":\"entity:2\",\"target_type\":\"trait\",\"target_id\":\"sys_character_party_member\",\"operator\":\"equals\",\"value\":0},\"effects\":[{\"target_scope\":\"entity:2\",\"target_type\":\"trait\",\"target_id\":\"sys_character_party_member\",\"operation\":\"set\",\"branches\":[{\"conditions\":null,\"operation\":\"set\",\"value_mode\":\"fixed\",\"value\":1}],\"target_system_key\":\"party_member\",\"value_mode\":\"fixed\",\"value\":1},{\"target_scope\":\"entity:1\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operation\":\"add\",\"branches\":[{\"conditions\":null,\"operation\":\"add\",\"value_mode\":\"fixed\",\"value\":3}],\"target_system_key\":\"quantity\",\"value_mode\":\"fixed\",\"value\":3}]}", "parent_storylet_id": 11, "choice_label": null, "choice_sort_order": 0, "branch_label": "Ask Mara to come with you", "branch_sort_order": 20 } { "id": 14, "uuid": "dff26531fcda94ce6b810de11117eccd", "game_id": 1, "name": "Mara — Decline", "storylet_key": "v60-mara-decline-82c32ea0", "description": "You thank Mara but keep your remaining supplies. There is still time to reach Stormgate, though the gate will not open for an empty hand.", "conditions": null, "effects": null, "max_occurrences": 1, "trigger_type": "manual", "status": "active", "created_at": "2026-09-25 18:22:38", "updated_at": "2026-09-25 18:22:38", "config": "{\"priority\":100,\"tick_cost\":0,\"chance_percent\":0,\"interrupts_travel\":\"0\"}", "parent_storylet_id": 11, "choice_label": null, "choice_sort_order": 0, "branch_label": "Decline and keep moving", "branch_sort_order": 30 } { "id": 25, "uuid": "", "game_id": 1, "name": "Test - Random Player Gets a Broken Ankle", "storylet_key": "", "description": "{{injury.name}} wandered off the path and broke an ankle.", "conditions": null, "effects": null, "max_occurrences": 1, "trigger_type": "manual", "status": "active", "created_at": "2026-09-29 12:25:54", "updated_at": "2026-09-29 12:25:54", "config": "{\"priority\":100,\"tick_cost\":0,\"interrupts_travel\":\"0\",\"bindings\":[{\"key\":\"injury\",\"label\":\"Injured Party\",\"selector\":\"random_party_character\"}],\"effects\":[{\"target_scope\":\"binding:injury\",\"target_type\":\"trait\",\"target_id\":\"binding_name_6865616c7468\",\"operation\":\"subtract\",\"branches\":[{\"conditions\":null,\"operation\":\"subtract\",\"value_mode\":\"fixed\",\"value\":15}],\"value_mode\":\"fixed\",\"value\":15},{\"target_scope\":\"binding:injury\",\"target_type\":\"trait\",\"target_id\":\"binding_name_6865616c7468\",\"operation\":\"subtract\",\"branches\":[{\"conditions\":null,\"operation\":\"subtract\",\"value_mode\":\"fixed\",\"value\":20}],\"value_mode\":\"fixed\",\"value\":20}]}", "parent_storylet_id": null, "choice_label": null, "choice_sort_order": 0, "branch_label": "", "branch_sort_order": 0 } { "id": 37, "uuid": "eff37bea28275d3429f0199570ed095e", "game_id": 1, "name": "[TEST] Wayfarer Feature Lab", "storylet_key": "test-suite-feature-lab", "description": "This is a temporary test Storylet for recent Wayfarer features. Choose one of the Branches below.", "conditions": null, "effects": null, "max_occurrences": null, "trigger_type": "manual", "status": "active", "created_at": "2026-09-29 17:57:17", "updated_at": "2026-09-29 17:57:17", "config": "{\"priority\":999,\"tick_cost\":0,\"chance_percent\":0,\"interrupts_travel\":\"0\"}", "parent_storylet_id": null, "choice_label": null, "choice_sort_order": 0, "branch_label": "", "branch_sort_order": 0 } { "id": 38, "uuid": "ba6c12dfd3bad47d9fe72b751b73c589", "game_id": 1, "name": "[TEST] Hidden Chamber", "storylet_key": "test-suite-hidden-chamber", "description": "SUCCESS: the Branch connected directly to this ROOT Storylet. If you offered the coin, its inventory Effects should already have been applied.", "conditions": null, "effects": null, "max_occurrences": null, "trigger_type": "manual", "status": "active", "created_at": "2026-09-29 17:57:17", "updated_at": "2026-09-29 17:57:17", "config": "{\"priority\":998,\"tick_cost\":0,\"chance_percent\":0,\"interrupts_travel\":\"0\"}", "parent_storylet_id": null, "choice_label": null, "choice_sort_order": 0, "branch_label": "", "branch_sort_order": 0 } { "id": 39, "uuid": "38e6d8d766a9d190aa0c1e730629c1d4", "game_id": 1, "name": "[TEST] Take a Test Coin", "storylet_key": "test-suite-take-coin", "description": "You pick up a worn silver coin for the test.", "conditions": null, "effects": null, "max_occurrences": null, "trigger_type": "manual", "status": "active", "created_at": "2026-09-29 17:57:17", "updated_at": "2026-09-29 17:57:17", "config": "{\"priority\":100,\"tick_cost\":0,\"chance_percent\":0,\"interrupts_travel\":\"0\",\"effects\":[{\"target_scope\":\"entity:10\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operation\":\"add\",\"branches\":[{\"conditions\":null,\"operation\":\"add\",\"value_mode\":\"fixed\",\"value\":1}],\"value_mode\":\"fixed\",\"value\":1,\"target_system_key\":\"quantity\"},{\"target_scope\":\"storylet\",\"target_type\":\"transition\",\"target_id\":\"37\",\"operation\":\"continue\",\"branches\":[{\"conditions\":null,\"operation\":\"continue\",\"value_mode\":\"fixed\",\"value\":\"\"}],\"value_mode\":\"fixed\",\"value\":\"\"}]}", "parent_storylet_id": 37, "choice_label": null, "choice_sort_order": 0, "branch_label": "1. Take a test coin (+1 Old Coin) and return here", "branch_sort_order": 10 } { "id": 40, "uuid": "e32f5c31cfcdd52a0a3c606ea1ef00cf", "game_id": 1, "name": "[TEST] Offer the Coin", "storylet_key": "test-suite-offer-coin", "description": "You place the old coin into the bowl. The stone door opens.", "conditions": null, "effects": null, "max_occurrences": null, "trigger_type": "manual", "status": "active", "created_at": "2026-09-29 17:57:17", "updated_at": "2026-09-29 17:57:17", "config": "{\"priority\":100,\"tick_cost\":0,\"chance_percent\":0,\"interrupts_travel\":\"0\",\"conditions\":{\"type\":\"condition\",\"scope\":\"entity:10\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operator\":\"greater_than_or_equal\",\"value\":1},\"effects\":[{\"target_scope\":\"entity:10\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operation\":\"subtract\",\"branches\":[{\"conditions\":null,\"operation\":\"subtract\",\"value_mode\":\"fixed\",\"value\":1}],\"value_mode\":\"fixed\",\"value\":1,\"target_system_key\":\"quantity\"},{\"target_scope\":\"entity:1\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operation\":\"add\",\"branches\":[{\"conditions\":null,\"operation\":\"add\",\"value_mode\":\"fixed\",\"value\":3}],\"value_mode\":\"fixed\",\"value\":3,\"target_system_key\":\"quantity\"},{\"target_scope\":\"storylet\",\"target_type\":\"transition\",\"target_id\":\"38\",\"operation\":\"continue\",\"branches\":[{\"conditions\":null,\"operation\":\"continue\",\"value_mode\":\"fixed\",\"value\":\"\"}],\"value_mode\":\"fixed\",\"value\":\"\"}]}", "parent_storylet_id": 37, "choice_label": null, "choice_sort_order": 0, "branch_label": "2. Offer the Old Coin (requires at least 1)", "branch_sort_order": 20 } { "id": 41, "uuid": "44a2983f66e7d1f1dc984283841597e8", "game_id": 1, "name": "[TEST] Random Pack Mishap", "storylet_key": "test-suite-random-pack-mishap", "description": "A strap snaps. {{loot.name}} spills from your pack. The selected item should stay the same everywhere in this Storylet occurrence.", "conditions": null, "effects": null, "max_occurrences": null, "trigger_type": "manual", "status": "active", "created_at": "2026-09-29 17:57:17", "updated_at": "2026-09-29 17:57:17", "config": "{\"priority\":100,\"tick_cost\":0,\"chance_percent\":0,\"interrupts_travel\":\"0\",\"bindings\":[{\"key\":\"loot\",\"label\":\"Lost Supply\",\"selector\":\"random_possessed_item\"}],\"effects\":[{\"target_scope\":\"binding:loot\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operation\":\"subtract\",\"branches\":[{\"conditions\":null,\"operation\":\"subtract\",\"value_mode\":\"random\",\"value_min\":1,\"value_max\":2}],\"value_mode\":\"random\",\"value_min\":1,\"value_max\":2,\"target_system_key\":\"quantity\"}]}", "parent_storylet_id": 37, "choice_label": null, "choice_sort_order": 0, "branch_label": "3. Random pack mishap (lose 1–2 of a possessed item)", "branch_sort_order": 30 } { "id": 42, "uuid": "0e703af5a8363a525031eeff5b7d0b16", "game_id": 1, "name": "[TEST] Recruit Mara", "storylet_key": "test-suite-recruit-mara", "description": "Mara joins the party. This tests Party Membership as a Set Effect.", "conditions": null, "effects": null, "max_occurrences": null, "trigger_type": "manual", "status": "active", "created_at": "2026-09-29 17:57:17", "updated_at": "2026-09-29 17:57:17", "config": "{\"priority\":100,\"tick_cost\":0,\"chance_percent\":0,\"interrupts_travel\":\"0\",\"conditions\":{\"type\":\"condition\",\"scope\":\"entity:2\",\"target_type\":\"trait\",\"target_id\":\"sys_character_party_member\",\"operator\":\"equals\",\"value\":0},\"effects\":[{\"target_scope\":\"entity:2\",\"target_type\":\"trait\",\"target_id\":\"sys_character_party_member\",\"operation\":\"set\",\"branches\":[{\"conditions\":null,\"operation\":\"set\",\"value_mode\":\"fixed\",\"value\":1}],\"value_mode\":\"fixed\",\"value\":1,\"target_system_key\":\"party_member\"}]}", "parent_storylet_id": 37, "choice_label": null, "choice_sort_order": 0, "branch_label": "4. Recruit Mara", "branch_sort_order": 40 } { "id": 43, "uuid": "24b66d6576bfb57e2a5fafd0f23c14f4", "game_id": 1, "name": "[TEST] Random Party Subject", "storylet_key": "test-suite-random-party-subject", "description": "{{victim.name}} was selected as the Storylet subject. The same bound character should remain selected for this Storylet occurrence.", "conditions": null, "effects": null, "max_occurrences": null, "trigger_type": "manual", "status": "active", "created_at": "2026-09-29 17:57:17", "updated_at": "2026-09-29 17:57:17", "config": "{\"priority\":100,\"tick_cost\":0,\"chance_percent\":0,\"interrupts_travel\":\"0\",\"bindings\":[{\"key\":\"victim\",\"label\":\"Selected Party Member\",\"selector\":\"random_party_character\"}]}", "parent_storylet_id": 37, "choice_label": null, "choice_sort_order": 0, "branch_label": "5. Pick a random party character (recruit Mara first)", "branch_sort_order": 50 } { "id": 44, "uuid": "8836ea396358bdc20369753c73b5d916", "game_id": 1, "name": "[TEST] Morning Clock Condition", "storylet_key": "test-suite-morning-clock", "description": "SUCCESS: this Branch was available because the current Game Clock Hour is before 12.", "conditions": null, "effects": null, "max_occurrences": null, "trigger_type": "manual", "status": "active", "created_at": "2026-09-29 17:57:17", "updated_at": "2026-09-29 17:57:17", "config": "{\"priority\":100,\"tick_cost\":0,\"chance_percent\":0,\"interrupts_travel\":\"0\",\"conditions\":{\"type\":\"condition\",\"scope\":\"time\",\"target_type\":\"clock\",\"target_id\":\"hour\",\"operator\":\"less_than\",\"value\":12}}", "parent_storylet_id": 37, "choice_label": null, "choice_sort_order": 0, "branch_label": "6. Morning-only Branch (Hour < 12)", "branch_sort_order": 60 } { "id": 45, "uuid": "9def26f4d0adac9d958b40b0c1550683", "game_id": 1, "name": "[TEST] Noon Clock Condition", "storylet_key": "test-suite-noon-clock", "description": "SUCCESS: this Branch was available because the current Game Clock Hour is 12 or later.", "conditions": null, "effects": null, "max_occurrences": null, "trigger_type": "manual", "status": "active", "created_at": "2026-09-29 17:57:17", "updated_at": "2026-09-29 17:57:17", "config": "{\"priority\":100,\"tick_cost\":0,\"chance_percent\":0,\"interrupts_travel\":\"0\",\"conditions\":{\"type\":\"condition\",\"scope\":\"time\",\"target_type\":\"clock\",\"target_id\":\"hour\",\"operator\":\"greater_than_or_equal\",\"value\":12}}", "parent_storylet_id": 37, "choice_label": null, "choice_sort_order": 0, "branch_label": "7. Noon-or-later Branch (Hour >= 12)", "branch_sort_order": 70 } { "id": 46, "uuid": "64c2848a60241bd9fa6f89a67b0c6944", "game_id": 1, "name": "[TEST] Return to Feature Lab", "storylet_key": "test-suite-return-to-lab", "description": "You return to the test lab.", "conditions": null, "effects": null, "max_occurrences": null, "trigger_type": "manual", "status": "active", "created_at": "2026-09-29 17:57:17", "updated_at": "2026-09-29 17:57:17", "config": "{\"priority\":100,\"tick_cost\":0,\"chance_percent\":0,\"interrupts_travel\":\"0\",\"effects\":[{\"target_scope\":\"storylet\",\"target_type\":\"transition\",\"target_id\":\"37\",\"operation\":\"continue\",\"branches\":[{\"conditions\":null,\"operation\":\"continue\",\"value_mode\":\"fixed\",\"value\":\"\"}],\"value_mode\":\"fixed\",\"value\":\"\"}]}", "parent_storylet_id": 38, "choice_label": null, "choice_sort_order": 0, "branch_label": "Return to the Feature Lab", "branch_sort_order": 10 } ------------------------------------------------------------------------------ DATA: wf_statuses ------------------------------------------------------------------------------ { "id": 1, "game_id": 1, "name": "Cholera", "description": "", "config": "{\"show_to_player\":\"1\",\"show_stage_to_player\":\"1\",\"show_time_remaining\":\"0\"}", "status": "active", "created_at": "2026-09-29 17:41:51", "updated_at": "2026-09-29 17:41:51" } ============================================================================== CONDITIONS / EFFECTS / TARGET MARKER INDEX ============================================================================== ------------------------------------------------------------------------------ MARKERS: admin/builder.php ------------------------------------------------------------------------------ 2 | /* WAYFARER_TICK_EFFECT_BRANCHES_V28 */ 20 | require_once __DIR__ . '/../includes/traits/functions.php'; 93 | $traits = $editorState['traits']; 97 | /* WAYFARER_STORYLET_BUILDER_V56 98 | * A + New Branch Storylet link can preselect its parent and choose a sensible 99 | * sibling order without making parent_storylet_id part of the generic request. 102 | $type === 'storylets' && 104 | !empty($_GET['parent_storylet_id']) 107 | (int)$_GET['parent_storylet_id']; 112 | FROM wf_storylets 125 | $record['parent_storylet_id'] = 130 | SELECT COALESCE(MAX(branch_sort_order), 0) 131 | FROM wf_storylets 133 | AND parent_storylet_id = ? 142 | $record['branch_sort_order'] = 149 | // WAYFARER_STORYLET_DUPLICATE_V83 172 | 'wf_storylets' 175 | 'Only Storylets can be duplicated.'; 178 | duplicateStoryletRecord( 240 | $traits 245 | $traits = $saveResult['traits']; 315 | $conditionData = $collectionState['conditionData']; 323 | 325 | 326 | 369 | window.wayfarerConditionData = 744 | 745 | 746 | ------------------------------------------------------------------------------ MARKERS: admin/games/edit.php ------------------------------------------------------------------------------ 8 | require_once __DIR__ . '/../../includes/traits/functions.php'; 31 | $traits = []; 32 | $playerTraits = []; 34 | /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ 35 | $gameTickEffects = []; 36 | $gameplayTickEffects = []; 39 | $startingChoiceItemTargets = []; 83 | isset($config['traits']) && 84 | is_array($config['traits']) 86 | $traits = $config['traits']; 89 | /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ 91 | isset($config['player_traits']) && 92 | is_array($config['player_traits']) 94 | $playerTraits = 96 | $config['player_traits'] 100 | /* WAYFARER_LOAD_GAME_TICK_EFFECTS_V33 */ 102 | isset($config['tick_effects']) && 103 | is_array($config['tick_effects']) 105 | $gameTickEffects = 107 | $config['tick_effects'] 144 | $gameplayTickEffects = 145 | $gameTickEffects; 180 | max(0, (int)($_POST['player_slots'] ?? 0)); 187 | // BUILD GAME + PLAYER TRAIT DEFINITIONS 188 | // WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 191 | $traitResult = buildTraitsFromPost( 192 | $_POST['traits'] ?? [], 196 | $traits = $traitResult['traits']; 198 | if ($traitResult['error'] !== '') { 199 | $error = $traitResult['error']; 202 | $playerTraitResult = 203 | buildTraitsFromPost( 204 | $_POST['player_traits'] ?? [], 208 | $playerTraits = 209 | $playerTraitResult['traits']; 213 | $playerTraitResult['error'] !== '' 216 | 'Player Traits: ' . 217 | $playerTraitResult['error']; 221 | /* WAYFARER_GAME_SETTINGS_UNIVERSAL_TARGETS_V56 */ 222 | $gameplayRuntimeTargets = 223 | gameplayRuntimeStateTargets( 226 | $traits, 227 | $playerTraits, 248 | * has a validation error. This keeps selected Travel Conditions intact on the 254 | $gameplayRuntimeTargets 282 | $startingChoiceItemTargets = 283 | wayfarerStartingChoiceItemQuantityTargets( 313 | $traits, 314 | $playerTraits, 319 | $startingChoiceItemTargets, 320 | $gameplayRuntimeTargets 357 | $traits 376 | /* WAYFARER_BUILD_GAME_TICK_EFFECTS_V33 */ 377 | $postedGameTickEffects = 378 | isset($_POST['tick_effects']) && 379 | is_array($_POST['tick_effects']) 381 | $_POST['tick_effects'] 385 | $gameplayTickEffects = 386 | $postedGameTickEffects; 390 | $gameTickEffectResult = 391 | gameplayBuildTickEffectsFromPost( 392 | $gameplayRuntimeTargets, 393 | $postedGameTickEffects, 394 | $gameplayRuntimeTargets 398 | $gameTickEffectResult['error'] !== 402 | $gameTickEffectResult['error']; 404 | $gameTickEffects = 405 | $gameTickEffectResult['effects']; 407 | $gameplayTickEffects = 408 | $gameTickEffects; 434 | * Traits own only the "traits" section of config. 469 | $startingDay = max(1, (int)($_POST['time_starting_day'] ?? 1)); 475 | 'ticks_per_day' => max(1, (int)($_POST['time_ticks_per_day'] ?? 1)), 478 | $hoursPerDay = max(1, (int)($_POST['time_hours_per_day'] ?? 24)); 479 | $startingHour = max(0, (int)($_POST['time_starting_hour'] ?? 8)); 488 | 'ticks_per_hour' => max(1, (int)($_POST['time_ticks_per_hour'] ?? 1)), 494 | $config['traits'] = 496 | $traits 499 | /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ 500 | $config['player_traits'] = 502 | $playerTraits 533 | /* WAYFARER_SAVE_GAME_TICK_EFFECTS_V33 */ 534 | if ($gameTickEffects) { 535 | $config['tick_effects'] = 537 | $gameTickEffects 541 | $config['tick_effects'] 623 | bin2hex(random_bytes(16)); 673 | 'traits', 719 | /* WAYFARER_GAME_SETTINGS_UNIVERSAL_TARGETS_RENDER_V56 */ 720 | $gameplayRuntimeTargets = 721 | gameplayRuntimeStateTargets( 724 | $traits, 725 | $playerTraits, 733 | $startingChoiceItemTargets = 734 | wayfarerStartingChoiceItemQuantityTargets( 747 | 754 | .wayfarer-game-builder-page > .admin-back-link, 755 | .wayfarer-game-builder-page > .admin-error-message, 757 | max-width: 1400px; 771 | * admin.css has older Game Settings rules aimed at .admin-form-card. 776 | @media (max-width: 900px) { 788 | 790 | 797 |

848 | data-game-tab="traits" 851 | Traits 968 |

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 |

Traits

1067 | 1068 | 1070 |
1071 |

Game Traits

1073 |

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 |

1096 |

Player Traits

1098 |

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 |

1192 | every normal gameplay tick. It is runtime clock state, not a Game Trait. 1210 | 1219 | 1228 | 1234 | 1240 | 1277 | $gameplayRuntimeTargets 1292 |

1293 | Endings are terminal game states. After any runtime state change, 1294 | Wayfarer checks these conditions in priority order. 1302 | $traits 1309 |