Drop7 Research
approaches/lifetime-objective/scenario/scenario.hppC++564 lines · 21.2 KB
#pragma once

// Scenario engine: the repository move loop with a pluggable reveal source.
//
// Why this exists
// ---------------
// `src/core/native/engine.hpp` draws a covered disc's number from the reveal
// RNG at the moment the disc is revealed, not when the row was created.
// `docs/exploratory/audit-01-engine-fidelity.md` finding M2 records that this
// means the engine defines no persistent hidden board: the same physical gray
// can hold different numbers depending on when and in what order it is opened.
// A fully specified, exactly solvable puzzle instance therefore cannot be
// expressed in the base engine at all.
//
// This header re-expresses the same move loop with the reveal source factored
// out, using the shared `drop7::` primitives (`findPoppers`, `applyGravity`,
// `raiseCoveredRow`, `scoreForWave`, `isBoardEmpty`, `placeDisc`,
// `legalColumns`, `isLegal`) so the rules are shared rather than re-derived.
// Two sources are provided:
//
//   * `StreamRevealSource`   - pulls from a `drop7::Mulberry32`, reproducing
//                              the base engine bit for bit.  Proven by
//                              `scenario-parity.cpp`.
//   * `LatentRevealSource`   - every covered cell carries a fixed hidden value
//                              assigned in advance.  Revealing a cell consumes
//                              that cell's value.  This is what makes a
//                              scenario a deterministic perfect-information
//                              puzzle with an exact optimum.
//
// The two sources have the same marginal distribution (i.i.d. uniform 1..7)
// but genuinely different dynamics.  Scores under `LatentRevealSource` are not
// bit-comparable with base-engine runs.  See the finding document.

#include "../../../src/core/native/engine.hpp"

#include <array>
#include <cstdint>
#include <cstdio>
#include <string>
#include <vector>

namespace drop7::scenario {

using LatentBoard = std::array<std::uint8_t, kCellCount>;
using RiseRow = std::array<std::uint8_t, kBoardSize>;

// ---------------------------------------------------------------------------
// Paired transforms: board and latent array move by exactly the same permutation
// ---------------------------------------------------------------------------

// The board result is produced by the shared primitive `drop7::applyGravity`;
// only the latent permutation is computed here, with the identical loop shape,
// so the two arrays can never drift apart.  `scenario-parity.cpp` additionally
// asserts the permutation agrees with `drop7::applyGravity` on random boards.
inline void applyGravityPaired(const Board& board, const LatentBoard& latent,
                               Board& out_board, LatentBoard& out_latent) {
  out_latent.fill(0);
  for (int column = 0; column < kBoardSize; ++column) {
    int destination = kBoardSize - 1;
    for (int row = kBoardSize - 1; row >= 0; --row) {
      const int index = indexOf(row, column);
      if (board[index] == kEmpty) continue;
      out_latent[indexOf(destination--, column)] = latent[index];
    }
  }
  out_board = applyGravity(board);
}

// Board legality and the row shift come from `drop7::raiseCoveredRow`.
inline bool raiseCoveredRowPaired(const Board& board, const LatentBoard& latent,
                                  const RiseRow& rise_latent, Board& out_board,
                                  LatentBoard& out_latent) {
  Board raised{};
  if (!raiseCoveredRow(board, raised)) return false;
  out_latent.fill(0);
  for (int row = 0; row < kBoardSize - 1; ++row) {
    for (int column = 0; column < kBoardSize; ++column) {
      out_latent[indexOf(row, column)] = latent[indexOf(row + 1, column)];
    }
  }
  for (int column = 0; column < kBoardSize; ++column) {
    out_latent[indexOf(kBoardSize - 1, column)] = rise_latent[column];
  }
  out_board = raised;
  return true;
}

// ---------------------------------------------------------------------------
// Reveal sources
// ---------------------------------------------------------------------------

// Reproduces `drop7::playMove` exactly: reveal values and the next visible disc
// both come from one Mulberry32 stream, in the engine's consumption order.
struct StreamRevealSource {
  Mulberry32* random = nullptr;

  std::uint8_t reveal(int /*index*/, const LatentBoard& /*latent*/) {
    return random->nextDisc();
  }
  std::uint8_t nextVisibleDisc() { return random->nextDisc(); }
  RiseRow riseValues() { return RiseRow{}; }  // latent unused in stream mode
  bool exhausted() const { return false; }
};

// Consumes the fixed hidden value that already sits under each covered cell.
// Cells introduced by a row rise take their values from `rise_rows`, and the
// visible disc sequence is read from `tape`.
struct LatentRevealSource {
  const std::uint8_t* tape = nullptr;
  int tape_length = 0;
  int tape_index = 0;
  const RiseRow* rise_rows = nullptr;
  int rise_count = 0;
  int rise_index = 0;
  bool tape_exhausted = false;
  bool rise_exhausted = false;
  bool invalid_latent = false;

  std::uint8_t reveal(int index, const LatentBoard& latent) {
    const std::uint8_t value = latent[index];
    if (value < 1 || value > kBoardSize) {
      invalid_latent = true;
      return 1;
    }
    return value;
  }
  // Beyond the tape the scenario is undefined; callers stop at the horizon, so
  // a 0 here marks "no further disc is specified" rather than a legal disc.
  std::uint8_t nextVisibleDisc() {
    if (tape_index >= tape_length) {
      tape_exhausted = true;
      return 0;
    }
    return tape[tape_index++];
  }
  RiseRow riseValues() {
    if (rise_index >= rise_count) {
      rise_exhausted = true;
      return RiseRow{};
    }
    return rise_rows[rise_index++];
  }
  bool exhausted() const { return tape_exhausted || rise_exhausted; }
};

// ---------------------------------------------------------------------------
// Move loop
// ---------------------------------------------------------------------------

// Statement-for-statement image of `drop7::resolveCascade`, differing only in
// where a revealed cell's number comes from and in carrying the latent array
// through the same gravity transform.
template <typename Reveal>
inline void resolveCascadeScenario(Board& board, LatentBoard& latent,
                                   Reveal& source, int starting_depth,
                                   std::int64_t& score,
                                   std::vector<Wave>& waves) {
  for (int depth = starting_depth;; ++depth) {
    int popper_count = 0;
    const auto poppers = findPoppers(board, popper_count);
    if (popper_count == 0) return;

    std::array<bool, kCellCount> popping{};
    Board cleared = board;
    LatentBoard cleared_latent = latent;
    for (int offset = 0; offset < popper_count; ++offset) {
      const int index = poppers[offset];
      popping[index] = true;
      cleared[index] = kEmpty;
      cleared_latent[index] = 0;
    }

    std::array<int, kCellCount> reveals{};
    int reveal_count = 0;
    constexpr std::array<std::array<int, 2>, 4> directions{{
        {{-1, 0}}, {{1, 0}}, {{0, -1}}, {{0, 1}},
    }};
    for (int row = 0; row < kBoardSize; ++row) {
      for (int column = 0; column < kBoardSize; ++column) {
        const int index = indexOf(row, column);
        const std::uint8_t cell = board[index];
        if (cell != kSolid && cell != kCracked) continue;
        int hits = 0;
        for (const auto& direction : directions) {
          const int neighbor_row = row + direction[0];
          const int neighbor_column = column + direction[1];
          if (inside(neighbor_row, neighbor_column) &&
              popping[indexOf(neighbor_row, neighbor_column)]) {
            ++hits;
          }
        }
        if (hits == 0) continue;
        const int hits_needed = cell == kSolid ? 2 : 1;
        if (hits >= hits_needed) {
          reveals[reveal_count++] = index;
        } else {
          cleared[index] = kCracked;
        }
      }
    }

    // Row-major reveal order is observable through subsequent chains, so it is
    // preserved exactly; only the value source changes.
    for (int offset = 0; offset < reveal_count; ++offset) {
      const int index = reveals[offset];
      cleared[index] = source.reveal(index, latent);
      cleared_latent[index] = 0;
    }
    const std::int64_t points = popper_count * scoreForWave(depth);
    score += points;
    waves.push_back({depth, popper_count, reveal_count, points});
    Board next_board{};
    LatentBoard next_latent{};
    applyGravityPaired(cleared, cleared_latent, next_board, next_latent);
    board = next_board;
    latent = next_latent;
  }
}

// Statement-for-statement image of `drop7::playMove`.
template <typename Reveal>
inline bool playScenarioMove(const State& state, const LatentBoard& latent_in,
                             int column, Reveal& source, MoveResult& result,
                             LatentBoard& latent_out) {
  if (state.game_over) return false;
  Board board = state.board;
  LatentBoard latent = latent_in;
  if (!placeDisc(board, column, state.next_disc)) return false;
  // The placed disc is a visible number; its cell carries no latent value.
  for (int row = kBoardSize - 1; row >= 0; --row) {
    const int index = indexOf(row, column);
    if (board[index] != kEmpty && state.board[index] == kEmpty) {
      latent[index] = 0;
      break;
    }
  }

  // Equivalent to `result = MoveResult{}` but keeps the wave vector's capacity,
  // which matters because the solver calls this millions of times per scenario.
  result.state = State{};
  result.score_delta = 0;
  result.waves.clear();
  result.cleared_board = false;
  result.level_advanced = false;
  std::int64_t first_score = 0;
  resolveCascadeScenario(board, latent, source, 1, first_score, result.waves);
  result.score_delta = first_score;
  result.cleared_board = isBoardEmpty(board);
  if (result.cleared_board) result.score_delta += kClearBonus;

  int level = state.level;
  int moves_remaining = state.moves_remaining - 1;
  bool game_over = false;
  if (moves_remaining == 0) {
    Board raised{};
    LatentBoard raised_latent{};
    const RiseRow rise_values = source.riseValues();
    if (!raiseCoveredRowPaired(board, latent, rise_values, raised,
                               raised_latent)) {
      game_over = true;
    } else {
      result.level_advanced = true;
      ++level;
      moves_remaining = kMovesPerLevel;
      result.score_delta += kLevelBonus;
      board = raised;
      latent = raised_latent;
      std::int64_t level_score = 0;
      const int next_depth =
          result.waves.empty() ? 1 : result.waves.back().depth + 1;
      resolveCascadeScenario(board, latent, source, next_depth, level_score,
                             result.waves);
      result.score_delta += level_score;
      if (isBoardEmpty(board)) {
        // Note: the base engine can award kClearBonus twice in one move (once
        // before the rise and once after).  audit-01 finding M1 flags this as a
        // divergence from the cited reference.  It is reproduced here on
        // purpose: the scenario engine must be the repository's game.
        result.score_delta += kClearBonus;
        result.cleared_board = true;
      }
    }
  }

  int legal_count = 0;
  legalColumns(board, legal_count);
  if (!game_over && legal_count == 0) game_over = true;

  result.state.board = board;
  result.state.next_disc =
      game_over ? state.next_disc : source.nextVisibleDisc();
  result.state.score = state.score + result.score_delta;
  result.state.level = level;
  result.state.moves_remaining = moves_remaining;
  result.state.moves_played = state.moves_played + 1;
  result.state.game_over = game_over;
  latent_out = latent;
  return true;
}

template <typename Reveal>
class ScenarioEngine {
 public:
  ScenarioEngine() = default;
  ScenarioEngine(const State& state, const LatentBoard& latent,
                 const Reveal& source)
      : state_(state), latent_(latent), source_(source) {}

  bool play(int column, MoveResult& result) {
    LatentBoard next_latent{};
    if (!playScenarioMove(state_, latent_, column, source_, result,
                          next_latent)) {
      return false;
    }
    state_ = result.state;
    latent_ = next_latent;
    return true;
  }

  const State& state() const { return state_; }
  State& mutableState() { return state_; }
  const LatentBoard& latent() const { return latent_; }
  Reveal& source() { return source_; }
  const Reveal& source() const { return source_; }

 private:
  State state_{};
  LatentBoard latent_{};
  Reveal source_{};
};

// ---------------------------------------------------------------------------
// Scenario record
// ---------------------------------------------------------------------------

// Number of future risen rows a scenario must specify to cover `horizon` moves
// starting with `moves_remaining` moves left in the current level.  One extra
// row is kept so that a solver never reads past the end.
inline int riseRowCount(int horizon, int moves_remaining) {
  return (horizon + moves_remaining + kMovesPerLevel - 1) / kMovesPerLevel + 1;
}

// Rises land on fixed move indices regardless of which columns are chosen, so
// the number of rises consumed after `moves` moves is a function of depth only.
inline int risesConsumed(int moves, int moves_remaining) {
  if (moves < moves_remaining) return 0;
  return (moves - moves_remaining) / kMovesPerLevel + 1;
}

struct Scenario {
  char id[17] = {};
  std::array<std::uint8_t, kCellCount> board{};
  std::array<std::uint8_t, kCellCount> latent{};
  std::uint8_t moves_remaining = kMovesPerLevel;
  std::uint8_t horizon = 0;
  std::vector<std::uint8_t> disc_tape;
  std::vector<RiseRow> rise_latent;
};

// FNV-1a 64 over a canonical byte image of every field except the id itself.
// Documented here so any language can recompute it: append board[0..48],
// latent[0..48], movesRemaining, horizon, |discTape| as one byte, discTape,
// |riseLatent| as one byte, then each rise row's seven bytes in order.
inline std::uint64_t scenarioContentHash(const Scenario& scenario) {
  std::uint64_t hash = 1469598103934665603ull;
  const auto absorb = [&hash](std::uint8_t byte) {
    hash ^= static_cast<std::uint64_t>(byte);
    hash *= 1099511628211ull;
  };
  for (std::uint8_t cell : scenario.board) absorb(cell);
  for (std::uint8_t cell : scenario.latent) absorb(cell);
  absorb(scenario.moves_remaining);
  absorb(scenario.horizon);
  absorb(static_cast<std::uint8_t>(scenario.disc_tape.size()));
  for (std::uint8_t disc : scenario.disc_tape) absorb(disc);
  absorb(static_cast<std::uint8_t>(scenario.rise_latent.size()));
  for (const RiseRow& row : scenario.rise_latent) {
    for (std::uint8_t value : row) absorb(value);
  }
  return hash;
}

inline void assignScenarioId(Scenario& scenario) {
  std::snprintf(scenario.id, sizeof(scenario.id), "%016llx",
                static_cast<unsigned long long>(scenarioContentHash(scenario)));
}

inline bool scenarioIdMatches(const Scenario& scenario) {
  char expected[17] = {};
  std::snprintf(expected, sizeof(expected), "%016llx",
                static_cast<unsigned long long>(scenarioContentHash(scenario)));
  return std::string(expected) == std::string(scenario.id);
}

// A scenario is well formed when the board is gravity-settled, every covered
// cell carries a latent value in 1..7, no visible cell carries one, the board
// has no already-poppable disc, and the tapes are long enough for the horizon.
inline bool validateScenario(const Scenario& scenario, std::string& reason) {
  const Board settled = applyGravity(scenario.board);
  if (settled != scenario.board) {
    reason = "board is not gravity-settled";
    return false;
  }
  for (int index = 0; index < kCellCount; ++index) {
    const std::uint8_t cell = scenario.board[index];
    const std::uint8_t value = scenario.latent[index];
    const bool covered = cell == kSolid || cell == kCracked;
    if (covered && (value < 1 || value > kBoardSize)) {
      reason = "covered cell without a latent value in 1..7";
      return false;
    }
    if (!covered && value != 0) {
      reason = "visible or empty cell carries a latent value";
      return false;
    }
    if (cell > kCracked) {
      reason = "cell value out of range";
      return false;
    }
  }
  int popper_count = 0;
  findPoppers(scenario.board, popper_count);
  if (popper_count != 0) {
    reason = "start position already contains a poppable disc";
    return false;
  }
  if (scenario.moves_remaining < 1 ||
      scenario.moves_remaining > kMovesPerLevel) {
    reason = "movesRemaining outside 1..5";
    return false;
  }
  if (scenario.horizon < 1) {
    reason = "horizon must be positive";
    return false;
  }
  if (scenario.disc_tape.size() != static_cast<std::size_t>(scenario.horizon)) {
    reason = "discTape length must equal horizon";
    return false;
  }
  for (std::uint8_t disc : scenario.disc_tape) {
    if (disc < 1 || disc > kBoardSize) {
      reason = "discTape entry outside 1..7";
      return false;
    }
  }
  const std::size_t required = static_cast<std::size_t>(
      riseRowCount(scenario.horizon, scenario.moves_remaining));
  if (scenario.rise_latent.size() != required) {
    reason = "riseLatent row count does not match the horizon";
    return false;
  }
  for (const RiseRow& row : scenario.rise_latent) {
    for (std::uint8_t value : row) {
      if (value < 1 || value > kBoardSize) {
        reason = "riseLatent entry outside 1..7";
        return false;
      }
    }
  }
  int legal_count = 0;
  legalColumns(scenario.board, legal_count);
  if (legal_count == 0) {
    reason = "start position has no legal column";
    return false;
  }
  if (!scenarioIdMatches(scenario)) {
    reason = "id does not match the content hash";
    return false;
  }
  return true;
}

// Replaces the randomness of a scenario while keeping the start position, the
// latent board, and the rise phase.  This is the operation a fair multi-tape
// evaluation needs: hold `board`, `latent`, and `movesRemaining` fixed and draw
// many independent tapes, so that "the shallow policy was unlucky" can be
// separated from "the shallow policy is worse here".  The id changes because
// the content changed; the start position is unchanged and can be compared
// directly across the family.
inline bool retapeScenario(const Scenario& source,
                           const std::vector<std::uint8_t>& disc_tape,
                           const std::vector<RiseRow>& rise_latent,
                           Scenario& out) {
  if (disc_tape.empty() || disc_tape.size() > 255) return false;
  const int horizon = static_cast<int>(disc_tape.size());
  if (static_cast<int>(rise_latent.size()) !=
      riseRowCount(horizon, source.moves_remaining)) {
    return false;
  }
  out = source;
  out.horizon = static_cast<std::uint8_t>(horizon);
  out.disc_tape = disc_tape;
  out.rise_latent = rise_latent;
  assignScenarioId(out);
  return true;
}

// Draws a fresh tape and fresh risen rows for the same start position.
inline bool resampleScenarioRandomness(const Scenario& source, int horizon,
                                       Mulberry32& random, Scenario& out) {
  std::vector<std::uint8_t> tape;
  for (int move = 0; move < horizon; ++move) tape.push_back(random.nextDisc());
  std::vector<RiseRow> rises;
  const int rise_count = riseRowCount(horizon, source.moves_remaining);
  for (int row = 0; row < rise_count; ++row) {
    RiseRow values{};
    for (int column = 0; column < kBoardSize; ++column) {
      values[column] = random.nextDisc();
    }
    rises.push_back(values);
  }
  return retapeScenario(source, tape, rises, out);
}

// Re-cuts a scenario to a shorter horizon, keeping the same start position,
// latent board, and tape prefix.  Used to find the largest exactly solvable
// horizon without regenerating anything.
inline bool reHorizonScenario(const Scenario& source, int horizon,
                              Scenario& out) {
  if (horizon < 1 || horizon > static_cast<int>(source.disc_tape.size())) {
    return false;
  }
  const int rises = riseRowCount(horizon, source.moves_remaining);
  if (rises > static_cast<int>(source.rise_latent.size())) return false;
  out = source;
  out.horizon = static_cast<std::uint8_t>(horizon);
  out.disc_tape.assign(source.disc_tape.begin(),
                       source.disc_tape.begin() + horizon);
  out.rise_latent.assign(source.rise_latent.begin(),
                         source.rise_latent.begin() + rises);
  assignScenarioId(out);
  return true;
}

inline State scenarioStartState(const Scenario& scenario) {
  State state;
  state.board = scenario.board;
  state.next_disc = scenario.disc_tape.empty() ? 1 : scenario.disc_tape[0];
  state.score = 0;
  state.level = 1;
  state.moves_remaining = scenario.moves_remaining;
  state.moves_played = 0;
  state.game_over = false;
  return state;
}

inline LatentRevealSource scenarioSource(const Scenario& scenario) {
  LatentRevealSource source;
  source.tape = scenario.disc_tape.data();
  source.tape_length = static_cast<int>(scenario.disc_tape.size());
  source.tape_index = 1;  // entry 0 is the disc already in hand
  source.rise_rows = scenario.rise_latent.data();
  source.rise_count = static_cast<int>(scenario.rise_latent.size());
  source.rise_index = 0;
  return source;
}

inline ScenarioEngine<LatentRevealSource> makeScenarioEngine(
    const Scenario& scenario) {
  return ScenarioEngine<LatentRevealSource>(
      scenarioStartState(scenario), scenario.latent, scenarioSource(scenario));
}

}  // namespace drop7::scenario