diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 14d52ed4..e2043254 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -47,6 +47,13 @@ jobs: with: submodules: true + # Windows Defender`s real-time scanning can lock/delay the visibility of freshly written files. + # This can lead to faling tests (since proofing creates many files and read them imediat) + - name: Exclude workspace from Windows Defender scanning + if: ${{ matrix.os == 'windows-latest' }} + shell: pwsh + run: Add-MpPreference -ExclusionPath "${{ github.workspace }}" + # Setup python - uses: actions/setup-python@v7 if: ${{ matrix.proof }} diff --git a/.github/workflows/pr-report.yml b/.github/workflows/pr-report.yml index f566bcf0..13c9c07c 100644 --- a/.github/workflows/pr-report.yml +++ b/.github/workflows/pr-report.yml @@ -29,7 +29,7 @@ jobs: # Retrieve the PR number from the workflow run - name: Get PR number id: get-pr - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const prs = await github.rest.pulls.list({ diff --git a/inkcpp/array.h b/inkcpp/array.h index b138d068..d425bfe4 100644 --- a/inkcpp/array.h +++ b/inkcpp/array.h @@ -247,6 +247,23 @@ class managed_restorable_array : public managed_array + void rebase_save(size_t position, Pred is_pending) + { + if (! is_saved() || position >= _last_size) { + return; + } + for (size_t i = position; i < _last_size; ++i) { + inkAssert(is_pending(i), "Cannot rebase save point over already committed data."); + } + _last_size = position; + } + bool has_changed() const { return base::size() != _last_size; } size_t last_size() const { return _last_size; } @@ -670,4 +687,94 @@ inline const unsigned char* allocated_restorable_array< return ptr; } +/** A managed @ref managed_array sibling class if pointers to element should be handed. + * If in dynamic mode, does allocate a new chunk of data each time the limit is reached, but keep + * the old one for the old data. + * If in static mode behaves like a static managed_array + * @attention cannot be snapshoted! + */ +template +class managed_instances +{ + static constexpr bool dynamic = config < 0; + static constexpr size_t fixed_n = dynamic ? 0 : static_cast(config); + +public: + managed_instances() + { + if constexpr (dynamic) { + T* block = reinterpret_cast(new (std::nothrow) unsigned char[sizeof(T) * abs(config)]); + inkAssert(block != nullptr, "Out of memory in inkcpp: managed_instances grow failed"); + inkAssert(( ::size_t ) block % alignof(T) == 0, "Miss aligend data in managed_instances"); + for (size_t i = 0; i < abs(config); ++i) { + _blocks.push() = block + i; + } + } + } + + managed_instances(const managed_instances&) = delete; + managed_instances& operator=(const managed_instances&) = delete; + + ~managed_instances() + { + if constexpr (dynamic) { + for (size_t i = 0; i < _blocks.size(); i = i == 0 ? abs(config) : i + next_chunk_size(i)) { + delete[] reinterpret_cast(_blocks[i]); + } + } + } + + T& push() + { + if constexpr (dynamic) { + if (_active == _blocks.size()) { + const size_t n_new_elements = next_chunk_size(_blocks.size()); + T* block + = reinterpret_cast(new (std::nothrow) unsigned char[sizeof(T) * n_new_elements]); + inkAssert(block != nullptr, "Out of memory in inkcpp: managed_instances grow failed"); + inkAssert(( ::size_t ) block % alignof(T) == 0, "Miss aligend data in managed_instances"); + for (size_t i = 0; i < n_new_elements; ++i) { + _blocks.push() = block + i; + } + } + return *_blocks[_active++]; + } else { + inkAssert(_active < fixed_n, "managed_instaces is full, and is static"); + T* slot = reinterpret_cast(_storage + _active * sizeof(T)); + ++_active; + return *slot; + } + } + + /** Marks every handou-out slot as free again. + * @attention The memory is retained + */ + void clear() { _active = 0; } + + config::statistics::container statistics() const + { + if constexpr (dynamic) { + return {static_cast(_blocks.size()), static_cast(_active)}; + } else { + return {static_cast(fixed_n), static_cast(_active)}; + } + } + +private: + /** Number of elements allocate for the next chunk. + * @param total_allocated total number of currently allocated slots + */ + static constexpr size_t next_chunk_size(size_t total_allocated) + { + size_t n = total_allocated / 2; + return n == 0 ? 1 : n; + } + + size_t _active = 0; + alignas( + dynamic ? alignof(unsigned char) : alignof(T) + ) unsigned char _storage[dynamic ? 1 : fixed_n * sizeof(T)]; + managed_array _blocks; +}; + } // namespace ink::runtime::internal diff --git a/inkcpp/choice.cpp b/inkcpp/choice.cpp index 611b78c1..3a9e618a 100644 --- a/inkcpp/choice.cpp +++ b/inkcpp/choice.cpp @@ -7,6 +7,7 @@ #include "choice.h" #include "output.h" +#include "runner_impl.h" #include "snapshot_impl.h" #include "string_table.h" #include "string_utils.h" @@ -16,30 +17,30 @@ namespace ink namespace runtime { - size_t choice::num_tags() const - { - return static_cast(std::distance(_tags_start, _tags_end)); - } + size_t choice::num_tags() const { return _tags_end - _tags_start; } const char* choice::get_tag(size_t index) const { - return (index < num_tags()) ? _tags_start[index].text() : nullptr; + return (index < num_tags()) ? _runner->tag_text(_tags_start + index) : nullptr; } choice& choice::setup( internal::basic_stream& in, internal::string_table& strings, internal::list_table& lists, - int index, uint32_t path, thread_t thread, const internal::snap_tag* tags_start, - const internal::snap_tag* tags_end + int index, uint32_t path, thread_t thread, thread_t scope_thread, + const internal::runner_impl& runner, size_t tags_start, size_t tags_end ) { // Index/path - _index = index; - _path = path; - _thread = thread; - _tags_start = tags_start; - _tags_end = tags_end; + _index = index; + _path = path; + _thread = thread; + _scope_thread = scope_thread; + _runner = &runner; + _tags_start = tags_start; + _tags_end = tags_end; char* text = nullptr; + in.commit_marker_extraction(); // if we only have one item in our output stream if (in.queued() == 2) { // If it's a string, just grab it. Otherwise, use allocation diff --git a/inkcpp/include/choice.h b/inkcpp/include/choice.h index 489ac305..f0fa8506 100644 --- a/inkcpp/include/choice.h +++ b/inkcpp/include/choice.h @@ -17,7 +17,6 @@ namespace runtime class runner_impl; class string_table; class list_table; - class snap_tag; } // namespace internal /** @@ -69,17 +68,19 @@ namespace runtime choice& setup( internal::basic_stream&, internal::string_table& strings, internal::list_table& lists, - int index, uint32_t path, thread_t thread, const internal::snap_tag* tags_start, - const internal::snap_tag* tags_end + int index, uint32_t path, thread_t thread, thread_t scope_thread, + const internal::runner_impl& runner, size_t tags_start, size_t tags_end ); protected: - const char* _text = nullptr; ///< @private - const internal::snap_tag* _tags_start = nullptr; ///< @private - const internal::snap_tag* _tags_end = nullptr; ///< @private - uint32_t _path = ~0U; ///< @private - thread_t _thread = ~0U; ///< @private - int _index = -1; ///< @private + const char* _text = nullptr; ///< @private + const internal::runner_impl* _runner = nullptr; ///< @private + size_t _tags_start = 0; ///< @private + size_t _tags_end = 0; ///< @private + uint32_t _path = ~0U; ///< @private + thread_t _thread = ~0U; ///< @private + thread_t _scope_thread = ~0U; ///< Used to avoid variable pollution from choices. @private + int _index = -1; ///< @private }; } // namespace runtime diff --git a/inkcpp/include/runner.h b/inkcpp/include/runner.h index e3d56a79..b3a5eb4a 100644 --- a/inkcpp/include/runner.h +++ b/inkcpp/include/runner.h @@ -379,6 +379,8 @@ class runner_interface * * Returns the choice object at a given index * + * @attention the returned pointer is only valid until @ref choose is called next; do not + * retain it across a choice being made. * @see num_choices * @param index index of the choice to access * @return choice object with info on the choice @@ -388,6 +390,8 @@ class runner_interface /** * Shorcut for accessing a choice * + * @attention the returned pointer is only valid until @ref choose is called next; do not + * retain it across a choice being made. * @see get_choice * @param index index of the choice to access * @return choice object with info on the choice diff --git a/inkcpp/include/types.h b/inkcpp/include/types.h index ef6c3d90..07cbc82f 100644 --- a/inkcpp/include/types.h +++ b/inkcpp/include/types.h @@ -147,7 +147,7 @@ struct value { /// @} -#ifdef __GNUCC__ +#ifdef __GNUC__ # pragma GCC diagnostic push # pragma GCC diagnostic ignored "-Wtautological-compare" #endif @@ -160,7 +160,7 @@ struct value { { static_assert(Ty != Ty, "No value getter for the selected type"); } -#ifdef __GNUCC__ +#ifdef __GNUC__ # pragma GCC diagnostic pop #endif }; diff --git a/inkcpp/list_table.cpp b/inkcpp/list_table.cpp index af6df219..c0bc9a90 100644 --- a/inkcpp/list_table.cpp +++ b/inkcpp/list_table.cpp @@ -865,9 +865,9 @@ list_flag list_table::get_list_id(const char* list_name) const list_table::list list_table::redefine(list lh, list rh) { + list res = create(); data_t* l = getPtr(lh.lid); data_t* r = getPtr(rh.lid); - list res = create(); data_t* o = getPtr(res.lid); // if the new list has no origin: give it the origin of the old value diff --git a/inkcpp/list_table.h b/inkcpp/list_table.h index feef1094..370575fc 100644 --- a/inkcpp/list_table.h +++ b/inkcpp/list_table.h @@ -11,6 +11,8 @@ #include "array.h" #include "./include/list.h" +#include + #ifdef INK_ENABLE_STL # include #endif @@ -469,12 +471,12 @@ class list_table : public snapshot_interface ); // defined list (meta data) - managed_array _list_end; - managed_array _flag_names; - managed_array _flag_values; - managed_array _list_names; + managed_array _list_end; + managed_array _flag_names; + managed_array _flag_values; + managed_array _list_names; /// keep track over lists accessed with get_var, and clear then at gc time - managed_array _list_handouts; + managed_instances _list_handouts; bool _valid; diff --git a/inkcpp/output.cpp b/inkcpp/output.cpp index 0576f11d..e6e0a17d 100644 --- a/inkcpp/output.cpp +++ b/inkcpp/output.cpp @@ -229,7 +229,7 @@ FString basic_stream::get() } #endif -size_t basic_stream::queued() const +size_t basic_stream::queued() { size_t start = find_start(); return _size - start; @@ -277,16 +277,7 @@ void basic_stream::get(value* ptr, size_t length) size_t basic_stream::find_first_of(value_type type, size_t offset /*= 0*/) const { - if (_size == 0) - return npos; - - // TODO: Cache? - for (size_t i = offset; i < _size; ++i) { - if (_data[i].type() == type) - return i; - } - - return npos; + return find_first_of([type](const value& v) { return v.type() == type; }, offset); } size_t basic_stream::find_last_of(value_type type, size_t offset /*= 0*/) const @@ -338,6 +329,13 @@ void basic_stream::forget() _save = npos; } +void basic_stream::rebase_save(size_t position) +{ + inkAssert(saved(), "No save point to rebase!"); + inkAssert(position <= _save, "Can not move save point forward!"); + _save = position; +} + template char* basic_stream::get_alloc(string_table& strings, list_table& lists); template char* basic_stream::get_alloc(string_table& strings, list_table& lists); @@ -402,7 +400,7 @@ char* basic_stream::get_alloc(string_table& strings, list_table& lists) break; case value_type::list: ptr = lists.toString(ptr, _data[i].get()); break; case value_type::list_flag: - ptr = lists.toString(ptr, _data[i].get()); + ptr = lists.toString(ptr, _data[i].get()); break; default: inkFail("cant convert expression to string!"); } @@ -430,13 +428,20 @@ size_t basic_stream::find_start() const { // Find marker (or start) size_t start = _size; + bool found = false; while (start > 0) { start--; - if (_data[start].type() == value_type::marker) + if (_data[start].type() == value_type::marker) { + found = true; break; + } } - // Make sure we're not violating a save point + // If the marker was already consumed, the save point is the start of the + // remaining output and data before it must not be extracted. + if (saved() && ! found) { + start = _save; + } if (saved() && start < _save) { // TODO: check if we don't reset save correct // at some point we can modifiy the output even behind save (probally discard?) and push a new @@ -447,6 +452,14 @@ size_t basic_stream::find_start() const return start; } +void basic_stream::commit_marker_extraction() +{ + const size_t marker = find_first_of(value_type::marker); + if (marker != npos && saved() && marker < _save) { + rebase_save(marker); + } +} + bool basic_stream::should_skip(size_t iter, bool& hasGlue, bool& lastNewline) const { if (_data[iter].printable() && _data[iter].type() != value_type::newline diff --git a/inkcpp/output.h b/inkcpp/output.h index 4c4dfbe7..ff05cc52 100644 --- a/inkcpp/output.h +++ b/inkcpp/output.h @@ -46,7 +46,10 @@ namespace runtime } // Returns the number of data items that will be extracted by the next get - size_t queued() const; + size_t queued(); + + // Commit output before extracting a marker-delimited value. + void commit_marker_extraction(); // Peeks the top entry const value& peek() const; @@ -92,6 +95,25 @@ namespace runtime */ size_t find_first_of(value_type type, size_t offset = 0) const; + /** Find the first entry in the output for which pred returns true + * @param predicate when evaluate to true will count as match + * @param offset into buffer + * @return index or @ref npos if no entry matches + */ + template + size_t find_first_of(Pred predicate, size_t offset = 0) const + { + if (_size == 0) { + return npos; + } + for (size_t i = offset; i < _size; ++i) { + if (predicate(_data[i])) { + return i; + } + } + return npos; + } + /** Find the last occurrence of the type in the output * @param type type to look for in the output * @param offset offset into buffer @@ -137,6 +159,7 @@ namespace runtime const unsigned char* snap_load(const unsigned char* data, const loader&); private: + void rebase_save(size_t position); size_t find_start() const; bool should_skip(size_t iter, bool& hasGlue, bool& lastNewline) const; diff --git a/inkcpp/runner_impl.cpp b/inkcpp/runner_impl.cpp index 238a688a..72496c82 100644 --- a/inkcpp/runner_impl.cpp +++ b/inkcpp/runner_impl.cpp @@ -123,17 +123,17 @@ void runner_impl::set_var( inkAssert(is_redef == false, "value pointer can only use to initelize variable!"); auto [name, ci] = val.get(); if (ci == 0) { - _stack.set(variableName, val); + _stack.define(variableName, val); } else { const value* dref = dereference(val); if (dref == nullptr) { value v = val; auto ref = v.get(); v.set(ref.name, 0); - _stack.set(variableName, v); + _stack.define(variableName, v); } else { - _ref_stack.set(variableName, val); - _stack.set(variableName, *dref); + _ref_stack.define(variableName, val); + _stack.define(variableName, *dref); } } } else { @@ -150,7 +150,7 @@ void runner_impl::set_var( _stack.set(variableName, src->redefine(val, _globals->lists())); } } else { - _stack.set(variableName, val); + _stack.define(variableName, val); } } } @@ -250,6 +250,12 @@ void runner_impl::assign_tags(std::initializer_list wheres) idy = _tags_begin[static_cast(tags_level::UNKNOWN)]; end = _tags_begin[static_cast(tags_level::UNKNOWN) + 1]; inkAssert(n == end - idy, "Same size in each iteration"); + // Reclassifying pending tags may reach into the region a later, still + // active save point already considers committed (e.g. a save() taken + // while these tags were still unresolved). Rebase the save point so it + // keeps enclosing this data instead of tripping the insert guard, but + // only if that data is still the pending UNKNOWN tags being moved here. + _tags.rebase_save(idx, [idy](size_t i) { return i >= idy; }); for (size_t i = 0; i < n; ++i) { const char* tag = _tags[idy + i * 2]; _tags.insert(idx + i) = tag; @@ -298,6 +304,17 @@ void runner_impl::clear_tags(tags_clear_level which) } } +void runner_impl::close_dangling_tag() +{ + const size_t marker = _output.find_first_of([](const value& v) { + return v.type() == value_type::marker && v.get() == marker_kind::start_tag; + }); + if (marker != _output.npos) { + _output.commit_marker_extraction(); + add_tag(_output.get_alloc(_globals->strings(), _globals->lists()), tags_level::UNKNOWN); + } +} + void runner_impl::fetch_tags(ip_t begin) { ip_t iter = begin; @@ -653,8 +670,8 @@ void runner_impl::choose(size_t index) _done = nullptr; // Collapse callstacks to the correct thread - _stack.collapse_to_thread(choiceThread); - _ref_stack.collapse_to_thread(choiceThread); + _stack.collapse_to_thread(c._scope_thread); + _ref_stack.collapse_to_thread(c._scope_thread); _threads.clear(); _eval.clear(); @@ -734,10 +751,9 @@ size_t runner_impl::snap(unsigned char* data, snapper& snapper) const ptr += _eval.snap(data ? ptr : nullptr, snapper); ptr += _tags_begin.snap(data ? ptr : nullptr, snapper); ptr += _tags.snap(data ? ptr : nullptr, snapper); - snapper.runner_tags = _tags.data(); - ptr = snap_write(ptr, _entered_global, should_write); - ptr = snap_write(ptr, _entered_knot, should_write); - ptr = snap_write(ptr, get_current_knot(), should_write); + ptr = snap_write(ptr, _entered_global, should_write); + ptr = snap_write(ptr, _entered_knot, should_write); + ptr = snap_write(ptr, get_current_knot(), should_write); if (_current_knot_id_backup != ~0U) { ptr = snap_write(ptr, _story->container_data(_current_knot_id_backup)._hash, should_write); } else { @@ -770,22 +786,21 @@ const unsigned char* runner_impl::snap_load(const unsigned char* data, loader& l int32_t seed; ptr = snap_read(ptr, seed); _rng.srand(seed); - ptr = snap_read(ptr, _evaluation_mode); - ptr = snap_read(ptr, _string_mode); - ptr = snap_read(ptr, _saved_evaluation_mode); - ptr = snap_read(ptr, _saved); - ptr = snap_read(ptr, _is_falling); - ptr = _output.snap_load(ptr, loader); - ptr = _stack.snap_load(ptr, loader); - ptr = _ref_stack.snap_load(ptr, loader); - ptr = _eval.snap_load(ptr, loader); - ptr = _tags_begin.snap_load(ptr, loader); - ptr = _tags.snap_load(ptr, loader); - loader.runner_tags = _tags.data(); - ptr = snap_read(ptr, _entered_global); - ptr = snap_read(ptr, _entered_knot); - _current_knot_id = ~0U; - ptr = snap_read(ptr, current_knot_name); + ptr = snap_read(ptr, _evaluation_mode); + ptr = snap_read(ptr, _string_mode); + ptr = snap_read(ptr, _saved_evaluation_mode); + ptr = snap_read(ptr, _saved); + ptr = snap_read(ptr, _is_falling); + ptr = _output.snap_load(ptr, loader); + ptr = _stack.snap_load(ptr, loader); + ptr = _ref_stack.snap_load(ptr, loader); + ptr = _eval.snap_load(ptr, loader); + ptr = _tags_begin.snap_load(ptr, loader); + ptr = _tags.snap_load(ptr, loader); + ptr = snap_read(ptr, _entered_global); + ptr = snap_read(ptr, _entered_knot); + _current_knot_id = ~0U; + ptr = snap_read(ptr, current_knot_name); if (current_knot_name) { ip_t knot_ip = _story->find_offset_for(current_knot_name); bool found = knot_ip != nullptr @@ -812,9 +827,13 @@ const unsigned char* runner_impl::snap_load(const unsigned char* data, loader& l _fallback_choice = nullopt; if (has_fallback_choice) { _fallback_choice.emplace(); - ptr = _fallback_choice.value().snap_load(ptr, loader); + ptr = _fallback_choice.value().snap_load(ptr, loader); + _fallback_choice.value()._runner = this; } ptr = _choices.snap_load(ptr, loader); + for (size_t i = 0; i < _choices.size(); ++i) { + _choices[i]._runner = this; + } return ptr; } @@ -1163,6 +1182,7 @@ void runner_impl::step() if (_evaluation_mode) { _eval.push(values::newline); } else { + close_dangling_tag(); if (! _output.ends_with(value_type::newline)) { _output << values::newline; } @@ -1277,6 +1297,7 @@ void runner_impl::step() case Command::END: read(); + close_dangling_tag(); _ptr = nullptr; break; @@ -1498,6 +1519,7 @@ void runner_impl::step() // Load value from output stream // Push onto stack + _output.commit_marker_extraction(); _eval.push(value{}.set( _output.get_alloc(_globals->strings(), _globals->lists()) )); @@ -1506,14 +1528,14 @@ void runner_impl::step() // == Tag commands case Command::START_TAG: { read(); - _output << values::marker; + _output << values::marker_start_tag; } break; case Command::END_TAG: { read(); - auto tag = _output.get_alloc(_globals->strings(), _globals->lists()); - add_tag(tag, tags_level::UNKNOWN); + // we can reach a END_TAG without an START_TAG + close_dangling_tag(); } break; // == Choice commands @@ -1567,11 +1589,9 @@ void runner_impl::step() // Fetch tags related to the current choice - size_t start = _tags_begin[static_cast(tags_level::CHOICE) + 1]; + size_t tags_start = _tags_begin[static_cast(tags_level::CHOICE) + 1]; assign_tags({tags_level::CHOICE}); - const snap_tag* tags_start = _tags.data() + start; - const snap_tag* tags_end - = _tags.data() + _tags_begin[static_cast(tags_level::CHOICE) + 1]; + size_t tags_end = _tags_begin[static_cast(tags_level::CHOICE) + 1]; // Create choice and record it choice* current_choice = nullptr; @@ -1581,9 +1601,19 @@ void runner_impl::step() } else { current_choice = &add_choice(); } + + // Capture local variables in a new scope. To avoid side effects from other choices. + thread_t scope_thread = _stack.fork_scope(); + { + thread_t t = _ref_stack.fork_scope(); + inkAssert(t == scope_thread, "ref_stack and stack should be in sync!"); + } + _stack.complete_thread(scope_thread); + _ref_stack.complete_thread(scope_thread); + current_choice->setup( _output, _globals->strings(), _globals->lists(), _choices.size() - 1, path, - current_thread(), tags_start, tags_end + current_thread(), scope_thread, *this, tags_start, tags_end ); // save stack at last choice if (_saved) { @@ -1622,12 +1652,8 @@ void runner_impl::step() _is_falling = false; frame_type type; - if (! _threads.empty()) { - on_done(false); - break; - } else if (_stack.has_frame(&type) && type == frame_type::function) // implicit return - // is only for - // functions + if (_stack.has_frame(&type) + && type == frame_type::function) // implicit return is only for functions { // push null and return _eval.push(values::null); @@ -1635,6 +1661,9 @@ void runner_impl::step() // HACK _ptr += sizeof(Command) + sizeof(CommandFlag); execute_return(); + } else if (! _threads.empty()) { + // a choice was found in a thread, note done position for to continue later + on_done(true); } else if (_ptr == _story->end()) { // check needed, because it colud exist an unnamed // toplevel container (empty named container stack // != empty container stack) @@ -1715,11 +1744,18 @@ void runner_impl::step() void runner_impl::on_done(bool setDone) { + close_dangling_tag(); + // If we're in a thread if (! _threads.empty()) { // Get the thread ID of the current thread thread_t completedThreadId = _threads.pop(); + // Record where this thread ends. This allowes to continue from any choice. + if (setDone) { + _threads.set(completedThreadId, _ptr); + } + // Push in a complete marker _stack.complete_thread(completedThreadId); _ref_stack.complete_thread(completedThreadId); diff --git a/inkcpp/runner_impl.h b/inkcpp/runner_impl.h index 1d5f6bfb..5085bbbd 100644 --- a/inkcpp/runner_impl.h +++ b/inkcpp/runner_impl.h @@ -123,6 +123,8 @@ class runner_impl return get_tag(index); }; + const char* tag_text(size_t idx) const { return _tags[idx].text(); } + virtual hash_t get_current_knot() const override; snapshot* create_snapshot() const override; @@ -221,6 +223,12 @@ class runner_impl // Fetch string only tags at Tag/Global level void fetch_tags(ip_t begin); + /** Simulate a END_TAG, to close a still open START_TAG marker. + * This might happen if a tag is closed in a not taken branch + * @todo evaluate if the tag should be dropped instead. + */ + void close_dangling_tag(); + // Special code for jumping from the current IP to another. // preserve_turns: if true, existing turns-since counters on visited knots are not reset // (used during snapshot migration to keep the restored turn values intact). diff --git a/inkcpp/snapshot_impl.cpp b/inkcpp/snapshot_impl.cpp index dd3b9061..2ce16554 100644 --- a/inkcpp/snapshot_impl.cpp +++ b/inkcpp/snapshot_impl.cpp @@ -154,15 +154,14 @@ size_t snap_choice::snap(unsigned char* data, const snapper& snapper) const ptr = snap_write(ptr, _index, should_write); ptr = snap_write(ptr, _path, should_write); ptr = snap_write(ptr, _thread, should_write); + ptr = snap_write(ptr, _scope_thread, should_write); // handle difference between no tag and first tag if (num_tags() == 0) { ptr = snap_write(ptr, false, should_write); } else { - ptr = snap_write(ptr, true, should_write); - std::uintptr_t offset_start = _tags_start - snapper.runner_tags; - ptr = snap_write(ptr, offset_start, should_write); - std::uintptr_t offset_end = _tags_end - snapper.runner_tags; - ptr = snap_write(ptr, offset_end, should_write); + ptr = snap_write(ptr, true, should_write); + ptr = snap_write(ptr, _tags_start, should_write); + ptr = snap_write(ptr, _tags_end, should_write); } ptr = snap_write(ptr, snapper.strings.get_id(_text), should_write); return static_cast(ptr - data); @@ -174,18 +173,15 @@ const unsigned char* snap_choice::snap_load(const unsigned char* data, const loa ptr = snap_read(ptr, _index); ptr = snap_read(ptr, _path); ptr = snap_read(ptr, _thread); + ptr = snap_read(ptr, _scope_thread); bool has_tags; ptr = snap_read(ptr, has_tags); if (has_tags) { - std::uintptr_t offset_start = 0; - ptr = snap_read(ptr, offset_start); - _tags_start = loader.runner_tags + offset_start; - std::uintptr_t offset_end = 0; - ptr = snap_read(ptr, offset_end); - _tags_end = loader.runner_tags + offset_end; + ptr = snap_read(ptr, _tags_start); + ptr = snap_read(ptr, _tags_end); } else { - _tags_start = nullptr; - _tags_end = nullptr; + _tags_start = 0; + _tags_end = 0; } size_t string_id; ptr = snap_read(ptr, string_id); diff --git a/inkcpp/snapshot_interface.h b/inkcpp/snapshot_interface.h index 1cf9fb6e..08a5b1a3 100644 --- a/inkcpp/snapshot_interface.h +++ b/inkcpp/snapshot_interface.h @@ -54,7 +54,6 @@ class snapshot_interface struct snapper { const string_table& strings; const char* story_string_table; - const snap_tag* runner_tags = nullptr; snapper(const string_table& strings, const char* story_string_table) : strings{strings} @@ -69,8 +68,7 @@ class snapshot_interface struct loader { managed_array& string_table; /// FIXME: make configurable const char* story_string_table; - const bool migratable = false; - const snap_tag* runner_tags = nullptr; + const bool migratable = false; managed_array& list_old_new_map; managed_array& list_list_matches; managed_array& list_value_matches; diff --git a/inkcpp/stack.cpp b/inkcpp/stack.cpp index db0a2708..59c459b6 100644 --- a/inkcpp/stack.cpp +++ b/inkcpp/stack.cpp @@ -30,6 +30,8 @@ void basic_stack::set(hash_t name, const value& val) *existing = val; } +void basic_stack::define(hash_t name, const value& val) { add(name, val); } + bool reverse_find_predicat(hash_t name, thread_t& skip, uint32_t& jumping, entry& e) { // Jumping @@ -407,6 +409,13 @@ thread_t basic_stack::fork_thread() return new_thread; } +thread_t basic_stack::fork_scope() +{ + thread_t new_scope = ScopeIdTag | (_next_scope++); + add(InvalidHash, value{}.set(new_scope, 0u)); + return new_scope; +} + void basic_stack::complete_thread(thread_t thread) { // Add a thread complete marker @@ -615,9 +624,14 @@ void basic_stack::fetch_values(basic_stack& stack) void basic_stack::push_values(basic_stack& stack) { - for (auto itr = base::begin(); - itr.get()->name != InvalidHash && itr.get()->data.type() != value_type::value_pointer; - itr.next()) { + // Skip over entries nulled by a collapse_to_thread() + auto itr = base::begin(); + if (! itr.done() && is_entry_null(*itr.get())) { + itr.next(is_entry_null); + } + for (; ! itr.done() && itr.get()->name != InvalidHash + && itr.get()->data.type() != value_type::value_pointer; + itr.next(is_entry_null)) { stack.set(itr.get()->name, itr.get()->data); } } diff --git a/inkcpp/stack.h b/inkcpp/stack.h index 2d87e8a9..4d013ecc 100644 --- a/inkcpp/stack.h +++ b/inkcpp/stack.h @@ -51,9 +51,17 @@ namespace runtime virtual ~basic_stack() = default; - // Sets existing value, or creates a new one at this callstack entry + /** Sets existing value, or creates a new one at this callstack entry + * @sa define() + */ void set(hash_t name, const value& val); + /** Always creates a new entry, shadowing existing. + * Needed for e.g. recursion + * @sa set() + */ + void define(hash_t name, const value& val); + // Gets an existing value, or nullptr const value* get(hash_t name) const; value* get(hash_t name); @@ -95,6 +103,11 @@ namespace runtime // Forks a new thread from the current callstack and returns that thread's unique id thread_t fork_thread(); + /** Forks a lightweight mark id, without consuming ids from fork_thread()'s id space. + * used e.g. to give choices a fresh scope to not bleed there variables + */ + thread_t fork_scope(); + // Mark a thread as "done". It's callstack is still preserved until collapse_to_thread is // called. void complete_thread(thread_t thread); @@ -130,6 +143,10 @@ namespace runtime thread_t _next_thread = 0; thread_t _backup_next_thread = 0; + // scope IDs @ref fork_scope() + thread_t _next_scope = 0; + static const thread_t ScopeIdTag = 0x8000'0000U; + static const hash_t NulledHashId = ~0U; }; diff --git a/inkcpp/value.h b/inkcpp/value.h index 46b56eec..e0440733 100644 --- a/inkcpp/value.h +++ b/inkcpp/value.h @@ -58,6 +58,13 @@ enum class value_type { jump_marker // callstack jump }; + +/** Notes the intend of the current open output stream. */ +enum class marker_kind : int32_t { + generic = 0, ///< marker with no specific meaning tracked (string interpolation, choice text) + start_tag, ///< marker opened by Command::START_TAG, to be closed by Command::END_TAG +}; + // add operator for value_type (to simplify usage templates). constexpr value_type operator+(value_type t, int i) { @@ -130,11 +137,17 @@ class value : public snapshot_interface { } + constexpr explicit value(marker_kind kind) + : int32_value{static_cast(kind)} + , _type{value_type::marker} + { + } + explicit value(const ink::runtime::value& val); bool set(const ink::runtime::value& val); ink::runtime::value to_interface_value(list_table&) const; -#ifdef __GNUCC__ +#ifdef __GNUC__ # pragma GCC diagnostic push # pragma GCC diagnostic ignored "-Wtautological-compare" #endif @@ -145,7 +158,7 @@ class value : public snapshot_interface static_assert(ty != ty, "No getter for this type defined!"); } -#ifdef __GNUCC__ +#ifdef __GNUC__ # pragma GCC diagnostic pop #endif @@ -550,14 +563,26 @@ inline constexpr value& value::set(uint32_t v) return *this; } -// define setter for values without storage template<> -inline constexpr value& value::set() +struct value::ret { + using type = marker_kind; +}; + +template<> +inline marker_kind value::get() const { - _type = value_type::marker; + return static_cast(int32_value); +} + +template<> +inline constexpr value& value::set(marker_kind kind) +{ + int32_value = static_cast(kind); + _type = value_type::marker; return *this; } +// define setter for values without storage template<> inline constexpr value& value::set() { @@ -671,12 +696,13 @@ inline constexpr value& value::set(uint32_t // static constexpr instantiations of flag values namespace values { - static constexpr value marker = value(value_type::marker); - static constexpr value glue = value(value_type::glue); - static constexpr value newline = value(value_type::newline); - static constexpr value func_start = value(value_type::func_start); - static constexpr value func_end = value(value_type::func_end); - static constexpr value null = value(value_type::null); - static constexpr value ex_fn_not_found = value(value_type::ex_fn_not_found); + static constexpr value marker = value(marker_kind::generic); + static constexpr value marker_start_tag = value(marker_kind::start_tag); + static constexpr value glue = value(value_type::glue); + static constexpr value newline = value(value_type::newline); + static constexpr value func_start = value(value_type::func_start); + static constexpr value func_end = value(value_type::func_end); + static constexpr value null = value(value_type::null); + static constexpr value ex_fn_not_found = value(value_type::ex_fn_not_found); } // namespace values } // namespace ink::runtime::internal diff --git a/inkcpp_test/Array.cpp b/inkcpp_test/Array.cpp index 58871aaa..e0b0917c 100644 --- a/inkcpp_test/Array.cpp +++ b/inkcpp_test/Array.cpp @@ -4,9 +4,17 @@ using namespace ink; using ink::runtime::internal::allocated_restorable_array; +using ink::runtime::internal::managed_instances; typedef allocated_restorable_array test_array; +namespace +{ +struct instance_payload { + int value; +}; +} // namespace + SCENARIO("a restorable array can hold values", "[array][unit][internals]") { GIVEN("an empty array") @@ -108,3 +116,188 @@ SCENARIO("a restorable array can save/restore/forget", "[array][unit][internals] } } } + +SCENARIO( + "a static managed_instances hands out a fixed number of instances", "[array][unit][internals]" +) +{ + GIVEN("a static managed_instances with capacity 3") + { + managed_instances instances; + + THEN("it starts out empty") + { + auto stats = instances.statistics(); + REQUIRE(stats.capacity == 3); + REQUIRE(stats.size == 0); + } + + WHEN("we push up to capacity") + { + instances.push().value = 1; + instances.push().value = 2; + instances.push().value = 3; + + THEN("statistics report the full, unchanged capacity") + { + auto stats = instances.statistics(); + REQUIRE(stats.capacity == 3); + REQUIRE(stats.size == 3); + } + + THEN("pushing beyond capacity fails") { REQUIRE_THROWS(instances.push()); } + } + } +} + +SCENARIO( + "a dynamic managed_instances grows in chunks without moving old data", + "[array][unit][internals]" +) +{ + GIVEN("a dynamic managed_instances with initial capacity 5") + { + managed_instances instances; + + THEN("it starts out at the configured initial capacity") + { + auto stats = instances.statistics(); + REQUIRE(stats.capacity == 5); + REQUIRE(stats.size == 0); + } + + WHEN("we fill the initial chunk") + { + instance_payload& first = instances.push(); + first.value = 42; + for (int i = 1; i < 5; ++i) { + instances.push().value = i; + } + + THEN("no growth was necessary yet") + { + auto stats = instances.statistics(); + REQUIRE(stats.capacity == 5); + REQUIRE(stats.size == 5); + } + + WHEN("we push past the initial chunk, forcing growth") + { + instances.push().value = 100; + + THEN("capacity grows by the expected chunk size") + { + auto stats = instances.statistics(); + REQUIRE(stats.capacity == 7); + REQUIRE(stats.size == 6); + } + + THEN("the previously handed out reference is still valid and unchanged") + { + REQUIRE(first.value == 42); + } + + WHEN("we push until the second chunk is also exhausted") + { + instances.push().value = 101; + + THEN("statistics reflect the still unchanged, now full capacity") + { + auto stats = instances.statistics(); + REQUIRE(stats.capacity == 7); + REQUIRE(stats.size == 7); + } + + WHEN("we push once more, forcing a third chunk") + { + instances.push().value = 102; + + THEN("capacity grows again by the expected chunk size") + { + auto stats = instances.statistics(); + REQUIRE(stats.capacity == 10); + REQUIRE(stats.size == 8); + } + + THEN("the very first reference handed out is still valid and unchanged") + { + REQUIRE(first.value == 42); + } + } + } + } + } + } +} + +SCENARIO( + "clear() on a dynamic managed_instances reuses previously allocated memory", + "[array][unit][internals]" +) +{ + GIVEN("a dynamic managed_instances with a few pushed instances") + { + managed_instances instances; + instance_payload& first = instances.push(); + first.value = 7; + instances.push().value = 8; + + WHEN("we clear it") + { + instances.clear(); + + THEN("size resets but capacity is unchanged") + { + auto stats = instances.statistics(); + REQUIRE(stats.size == 0); + REQUIRE(stats.capacity == 5); + } + + WHEN("we push again") + { + instance_payload& reused = instances.push(); + + THEN("the same memory is handed out again") { REQUIRE(&reused == &first); } + } + } + } +} + +SCENARIO( + "a dynamic managed_instances with a single initial slot still grows correctly", + "[array][unit][internals]" +) +{ + GIVEN("a dynamic managed_instances configured with an initial capacity of one") + { + managed_instances instances; + + THEN("it starts out at the configured initial capacity") + { + auto stats = instances.statistics(); + REQUIRE(stats.capacity == 1); + REQUIRE(stats.size == 0); + } + + WHEN("we push well past the initial capacity") + { + instance_payload& first = instances.push(); + first.value = 1; + for (int i = 2; i <= 5; ++i) { + instances.push().value = i; + } + + THEN("every push succeeded and grew capacity to keep up") + { + auto stats = instances.statistics(); + REQUIRE(stats.size == 5); + REQUIRE(stats.capacity >= 5); + } + + THEN("the first handed out reference is still valid and unchanged") + { + REQUIRE(first.value == 1); + } + } + } +} diff --git a/inkcpp_test/Fixes.cpp b/inkcpp_test/Fixes.cpp index 1cf5e13e..510e78ad 100644 --- a/inkcpp_test/Fixes.cpp +++ b/inkcpp_test/Fixes.cpp @@ -413,3 +413,357 @@ SCENARIO("Node text lookup error after loading story", "[regression][runtime][mi } } } + +SCENARIO("Dangling tag does not leak past a divert #170", "[regression][tags][runtime]") +{ + GIVEN("a shuffled block whose tag is never closed on the taken branch") + { + std::unique_ptr ink{story::from_file(INK_TEST_RESOURCE_DIR "case1_repro.bin")}; + runner thread = ink->new_runner(); + + THEN("the line reads correctly and getline does not throw") + { + REQUIRE(thread->getline() == "Le soleil brille sur la place.\n"); + } + } +} + +SCENARIO( + "Choice created by a recursively-threaded knot can be resumed #170", + "[regression][runtime][threads]" +) +{ + GIVEN("a knot that recurses via '<-' and offers a choice from a nested thread") + { + std::unique_ptr ink{story::from_file(INK_TEST_RESOURCE_DIR "case2_repro.bin")}; + runner thread = ink->new_runner(); + + WHEN("the story runs to the choice presented by the nested thread") + { + thread->getall(); + REQUIRE(thread->num_choices() == 1); + CHECK(std::string(thread->get_choice(0)->text()) == "Vers B..."); + + AND_WHEN("the choice is selected") + { + thread->choose(0); + + THEN("execution resumes at the divert target instead of throwing") + { + REQUIRE(thread->getall() == "Arrived.\n"); + } + } + } + } +} + +SCENARIO( + "Newline inside an interpolated string is not mistaken for a dangling tag", + "[regression][tags][runtime]" +) +{ + GIVEN("a string literal built from a conditional with its own line breaks") + { + std::unique_ptr ink{story::from_file(INK_TEST_RESOURCE_DIR "case3_repro.bin")}; + runner thread = ink->new_runner(); + + THEN("the composed string is printed intact, with no spurious tag") + { + REQUIRE(thread->getall() == "prefix\nline one\nsuffix\n"); + REQUIRE(thread->has_tags() == false); + } + } +} + +SCENARIO( + "Choice tags remain valid after later choices force the tag array to grow #170", + "[regression][tags][runtime]" +) +{ + GIVEN("a knot offering six choices, each carrying four of its own tags") + { + std::unique_ptr ink{story::from_file(INK_TEST_RESOURCE_DIR "case4_repro.bin")}; + runner thread = ink->new_runner(); + + WHEN("all six choices are discovered in a single pass") + { + thread->getall(); + + THEN("every choice still reports its own tags, not another choice's or garbage") + { + REQUIRE(thread->num_choices() == 6); + const char* expected[6][4] = { + {"a1", "a2", "a3", "a4"}, + {"b1", "b2", "b3", "b4"}, + {"c1", "c2", "c3", "c4"}, + {"d1", "d2", "d3", "d4"}, + {"e1", "e2", "e3", "e4"}, + {"f1", "f2", "f3", "f4"}, + }; + for (int i = 0; i < 6; ++i) { + REQUIRE(thread->get_choice(static_cast(i))->num_tags() == 4); + for (int j = 0; j < 4; ++j) { + CHECK( + std::string(thread->get_choice(static_cast(i)) + ->get_tag(static_cast(j))) + == expected[i][j] + ); + } + } + } + } + } +} + +SCENARIO("Storylets weave threads share temporaries across gathers #170", "[regression][runtime]") +{ + GIVEN("the Storylets story, which re-enters a recursive weave via '<-' each turn") + { + std::unique_ptr ink{story::from_file(INK_TEST_RESOURCE_DIR "Storylets.bin")}; + runner thread = ink->new_runner(); + + WHEN("all three storylets are visited one at a time") + { + std::string content = thread->getall(); + REQUIRE(content == "The beginning!\n"); + REQUIRE(thread->num_choices() == 2); + CHECK(thread->get_choice(0)->text() == std::string("Visit the Avocado Witch")); + CHECK(thread->get_choice(1)->text() == std::string("Visit the Crumpet King")); + + thread->choose(0); + content = thread->getall(); + REQUIRE(content == "You visit the witch.\n"); + REQUIRE(thread->num_choices() == 1); + CHECK(thread->get_choice(0)->text() == std::string("Visit the Crumpet King")); + + thread->choose(0); + content = thread->getall(); + REQUIRE(content == "You visit the king.\n"); + REQUIRE(thread->num_choices() == 1); + CHECK( + thread->get_choice(0)->text() + == std::string("Now you have met the King, visit the Banana Boy!") + ); + + thread->choose(0); + content = thread->getall(); + THEN("no 'done' point is missing and the story falls back once storylets run out") + { + REQUIRE(content == "You visit the boy.\nThere was nothing else to do.\n"); + REQUIRE_FALSE(thread->has_choices()); + } + } + } +} + +SCENARIO( + "A story from the original issue reproduces without missing done pointers #170", + "[regression][runtime]" +) +{ + GIVEN("the PaniqueAMandonez story from the issue report") + { + std::unique_ptr ink{story::from_file(INK_TEST_RESOURCE_DIR "PaniqueAMandonez.bin")}; + runner thread = ink->new_runner(); + + WHEN("the story starts and the first choice is taken") + { + thread->getall(); + REQUIRE(thread->num_choices() == 2); + CHECK(std::string(thread->get_choice(0)->text()) == std::string("Commencer")); + CHECK(std::string(thread->get_choice(1)->text()) == std::string("Informations sur le jeu")); + thread->choose(0); + thread->getall(); + REQUIRE(thread->num_choices() == 2); + CHECK(std::string(thread->get_choice(0)->text()) == std::string("Observer le village")); + CHECK(std::string(thread->get_choice(1)->text()) == std::string("Descendre")); + thread->choose(0); + + THEN("resuming from the branch does not trip the done-pointer assertion") + { + REQUIRE_NOTHROW(thread->getall()); + } + } + } +} + +SCENARIO( + "CLI choice streams from the original issue do not corrupt save points #170", + "[regression][runtime]" +) +{ + GIVEN("the PaniqueAMandonez story and the raw choice sequences reported in the issue") + { + std::unique_ptr ink{story::from_file(INK_TEST_RESOURCE_DIR "PaniqueAMandonez.bin")}; + + for (const std::string& sequence : {std::string("11111433232214"), std::string("1232322142")}) { + runner thread = ink->new_runner(); + std::string remaining = sequence; + + WHEN("the story is driven through the same choice stream the CLI saw") + { + THEN("no save-point or output-stream assertion is raised") + { + while (true) { + while (thread->can_continue()) { + REQUIRE_NOTHROW(thread->getline()); + } + if (! thread->has_choices()) { + break; + } + if (remaining.empty()) { + break; + } + const int choice = remaining.front() - '0'; + REQUIRE(choice >= 1); + REQUIRE(choice <= static_cast(thread->num_choices())); + REQUIRE_NOTHROW(thread->choose(static_cast(choice - 1))); + remaining.erase(remaining.begin()); + } + } + } + } + } +} + +SCENARIO( + "Sequence inside an interpolated string does not drop content across a divert #170", + "[regression][runtime]" +) +{ + GIVEN("a string literal built from a cycle whose branches converge via a divert") + { + std::unique_ptr ink{story::from_file(INK_TEST_RESOURCE_DIR "case5_repro.bin")}; + runner thread = ink->new_runner(); + + THEN("every cycle step is composed into the string intact, in order") + { + REQUIRE(thread->getline() == "prefix AAAA suffix\n"); + REQUIRE(thread->getline() == "prefix BBBB suffix\n"); + REQUIRE(thread->getline() == "prefix CCCC suffix\n"); + REQUIRE(thread->getline() == "prefix AAAA suffix\n"); + REQUIRE(thread->getline() == "After line.\n"); + } + } +} + +SCENARIO( + "One more choice on top of the fixed CLI stream still corrupts the save point #170", + "[regression][runtime]" +) +{ + GIVEN( + "the PaniqueAMandonez story and the previously-fixed choice stream with one extra choice " + "appended, as reported by smwhr after PR #173" + ) + { + std::unique_ptr ink{story::from_file(INK_TEST_RESOURCE_DIR "PaniqueAMandonez.bin")}; + runner thread = ink->new_runner(); + std::string remaining = "111114332322142"; + + THEN("no save-point or output-stream assertion is raised") + { + while (true) { + while (thread->can_continue()) { + REQUIRE_NOTHROW(thread->getline()); + } + if (! thread->has_choices()) { + break; + } + if (remaining.empty()) { + break; + } + const int choice = remaining.front() - '0'; + REQUIRE(choice >= 1); + REQUIRE(choice <= static_cast(thread->num_choices())); + REQUIRE_NOTHROW(thread->choose(static_cast(choice - 1))); + remaining.erase(remaining.begin()); + } + } + } +} + +SCENARIO( + "A dangling empty tag is not left in front of a real tag #170", "[regression][tags][runtime]" +) +{ + GIVEN( + "the PaniqueAMandonez story driven through the same choice stream that exposes the " + "leading-comma tag glitch smwhr reported after PR #173" + ) + { + std::unique_ptr ink{story::from_file(INK_TEST_RESOURCE_DIR "PaniqueAMandonez.bin")}; + runner thread = ink->new_runner(); + std::string remaining = "11111433232214"; + + int entrouverteSeen = 0; + bool found = false; + ink::size_t foundTags = 0; + std::string foundTag0; + + while (true) { + while (thread->can_continue()) { + std::string line = thread->getline(); + if (! found && line.find("entrouverte") != std::string::npos) { + ++entrouverteSeen; + if (entrouverteSeen == 2) { + found = true; + foundTags = thread->num_tags(); + if (foundTags > 0) { + foundTag0 = thread->get_tag(0); + } + } + } + } + if (! thread->has_choices() || remaining.empty()) { + break; + } + const int choice = remaining.front() - '0'; + thread->choose(static_cast(choice - 1)); + remaining.erase(remaining.begin()); + } + + THEN( + "the line describing the Maire's office carries only its own tag, with none left over " + "from an earlier line" + ) + { + REQUIRE(found); + REQUIRE(foundTags == 1); + REQUIRE(foundTag0 == "didascalie"); + } + } +} + +SCENARIO( + "Returning from a nested tunnel back into a threaded weave does not desync the ref stack #170", + "[regression][runtime][threads]" +) +{ + GIVEN("a title knot that tunnels through act1/act_1_sc_1 and back out to playloop") + { + std::unique_ptr ink{story::from_file(INK_TEST_RESOURCE_DIR "case6_repro.bin")}; + runner thread = ink->new_runner(); + + WHEN("Play is chosen, reaching the choice offered inside the nested weave") + { + thread->getall(); + REQUIRE(thread->num_choices() == 1); + CHECK(std::string(thread->get_choice(0)->text()) == "Play"); + thread->choose(0); + thread->getall(); + REQUIRE(thread->num_choices() == 2); + + THEN( + "selecting a choice and returning through the tunnel reaches playloop without " + "throwing" + ) + { + REQUIRE_NOTHROW(thread->choose(0)); + std::string content; + REQUIRE_NOTHROW(content = thread->getall()); + REQUIRE(content == "Express Joy\nThe PLAYLOOP\n"); + } + } + } +} diff --git a/inkcpp_test/ink/PaniqueAMandonez.ink b/inkcpp_test/ink/PaniqueAMandonez.ink new file mode 100644 index 00000000..9e075e80 --- /dev/null +++ b/inkcpp_test/ink/PaniqueAMandonez.ink @@ -0,0 +1,210 @@ +INCLUDE world/locations.ink +INCLUDE includes/verbose.ink +INCLUDE story/triggers.ink +INCLUDE world/characters.ink +INCLUDE world/topics.ink +INCLUDE story/conversation.ink +INCLUDE story/investigation.ink + + + + +#TITLE: Panique à Mandonez +#AUTHOR: ju / smwhr + +-> menu + +=== menu + +-(opts) + + [Commencer] #CLEAR + -> init + +(credits){not came_from(-> credits)} Informations sur le jeu + + Toute ressemblance avec des faits et des personnages existants ou ayant existé serait purement fortuite et ne pourrait être que le fruit d’une pure coïncidence. La ville de Mandonez-Savès n'existe pas et se base sur une vision très biaisée du Gers que je visite régulièrement. + + Ce jeu a été écrit en ink à l'occasion du Concours de Fiction Interactive Francophone 2024. + + N'hésitez pas à signaler tout ce que que vous pensez utile de signaler (bugs, fautes d'orthographe, incohérence, etc), cette œuvre n'ayant pas eu toute l'attention qu'elle aurait dû durant sa phase de test :) + + Version 2 (2024-03-08) ; IFID : 32F42657-F7A8-4D02-8324-7FF133DC14A9 + ->opts + -> init + +=== init +Mandonez-Savès, Gers, 5 juillet 1992, 13h + +Le bus des Liaisons Occitanes s'arrête au milieu du village. + +Vous êtes ici parce que vous avez reçu une lettre : + +``` +Camille, + +Lorsque cette lettre vous trouvera, il sera déjà trop tard. +Retrouvez moi à Mandonez le 5 juillet prochain dans l'Ancien Clocher. +Et vous saurez la Vérité. + +Ludivine, Comtesse de Peyrian +``` + +-(opts) +* [Observer le village # investigate] + ~ describe(PlaceMairie) + Le chauffeur émet un grognement d'impatience. # didascalie + -> opts ++ [Descendre # action] + ~ describe(PlaceMairie) + + À peine avez-vous posé le pied au sol que les portes du bus se referment derrière vous. #didascalie + Le bus redémarre. #didascalie + ~MoveTo(Me, PlaceMairie) + +- +-> loop + + +=== loop + +<- exits(CurrentLocation, ->on_exit) + +~temp CharactersInRoom = CharactersIn(LocationOf(Me)) - (Me) + +{CharactersInRoom ? Maire : <-ConversationMaire(->on_flow, ->on_choice)} +{CharactersInRoom ? Comtesse : <-ConversationComtesse(->on_flow, ->on_choice)} +{CharactersInRoom ? Pretre : <-ConversationPretre(->on_flow, ->on_choice)} +{CharactersInRoom ? Barmaid : <-ConversationBarmaid(->on_flow, ->on_choice)} +{CharactersInRoom ? Tenor : <-ConversationTenor(->on_flow, ->on_choice)} +{CharactersInRoom ? Chatelain : <-ConversationChatelain(->on_flow, ->on_choice)} +{CurrentLocation == BureauMaire: <-InvestigateBureauMaire(->on_flow, ->on_choice)} +{CurrentLocation == Ecole: <-InvestigateEcole(->on_flow, ->on_choice)} +{CurrentLocation == ArriereBar: <-InvestigateArriereBar(->on_flow, ->on_choice)} +{CurrentLocation == PlacardElectrique: <-InvestigatePlacardElectrique(->on_flow, ->on_choice)} +{CurrentLocation == BureauChatelain: <-InvestigateBureauChatelain(->on_flow, ->on_choice)} + +//Gather ++[Attendre # wait] + Le temps s'écoule lentement. #didascalie + ->on_choice + ++ -> +-(on_flow) +~temp NeedExits = TURNS_SINCE(->exits) > 0 +{NeedExits: <- exits(CurrentLocation, ->on_exit)} ++{NeedExits}[Attendre # wait] + Le temps s'écoule lentement. #didascalie + ->on_choice +->DONE +-(on_choice) +-> triggers -> loop + +=== on_exit +-> triggers -> +Vous {toward(LocationOf(Me))} {verbose(LocationOf(Me))}. + +~describe(LocationOf(Me)) + +{Locations hasnt LocationOf(Me): + ~Locations+=LocationOf(Me) +} + +~temp CharactersInRoom = CharactersIn(LocationOf(Me)) - (Me) //do not display that we are here ! +{LocationOf(Me) == SalleBal: + - ~CharactersInRoom = CharactersIn(BalLocations) +} + +{BalLocations ? LocationOf(Me) and LIST_COUNT(LocationOf(Me)) == 1: + - ~CharactersInRoom = () +} + + +<>{LIST_COUNT(CharactersInRoom) > 0: + - \ {listWithCommasAnd(CharactersInRoom, "")} {is_are(CharactersInRoom)} là. # didascalie +} + +-> triggers_aftermove -> + +-> loop + + +=== meet_the_comtesse +~Characters += Comtesse +La jeune fille sursaute. Elle ne vous avait pas vue. #didascalie +– Camille ! Je désespérais de vous voir arriver. Je n'ai pas beaucoup de temps alors écoutez moi bien. Tout le monde me recherche, mais ce n'est pas le plus important. Ne vous inquiétez pas pour moi. Je voulais me montrer à vous pour vous rassurer : il ne m'est rien arrivé. Pas encore en tout cas. Je vais fuir. Mais vous pouvez faire quelque chose pour moi : mes parents ont fait quelque chose de terrible, il faut que vous découvriez quoi. Ça a un rapport avec l'école, le Maire est dans le coup. Il cache quelque chose. Trouvez ce que c'est et révélez ce que vous aurez découvert ! Je dois y aller maintenant. +~Topics+=ComplotEcole + +* – C'est compris ! + La Comtesse vous adresse un dernier signe avant de s'éloigner. #didascalie + ~ MoveTo(Comtesse,NoWhere) + ~ MoveTo(Maire, BureauMaire) + ~ MoveTo(Tenor, Bar) + +- +->loop + + +=== confront_maire + +– Et quoi donc ? + – Vous avez provoqué une inondation dans l'école afin de justifier sa destruction pendant que vous complotiez pour faire construire un centre aquatique à la place ! + – J'avoue que vous avez bien joué sur ce coup là. Mais qu'importe. Tout est déjà en marche, rien ne peut arrêter ce projet maintenant. Et même, je vais être beau joueur : voilà un carton pour le bal au Chateau de Peyrian, ce soir. Avec ou sans la petite Ludivine, que je n'ai pas trouvée d'ailleurs, il aura lieu. Et je vous mets au défi de trouver qui que ce soit qui s'oppose au projet ! + Le Maire vous tend un carton avec le plan vers le chateau. # didalie + ~knowRouteToChateau = true + – Allez ouste, on se voit tout à l'heure ! + Le Maire vous empoigne et vous met à la porte de son bureau. # didascalie + ~ MoveTo(Me, Mairie) + ~ MoveTo(Maire, BalMaire) + ~ MoveTo(Barmaid, BalServeuse) + ~ MoveTo(Pretre, BalPretre) + ~ MoveTo(Chatelain, BalChatelain) + ~ MoveTo(Tenor, BalTenor) + Vous vous retrouvez dans le hall de la Mairie. <> + ~describe(Mairie) + ~ Moment = Soir +- +->loop + + +=== ouverture_caveau + +Un pan de mur discret, camouflé par des étagères en apparence anodines, s'ouvre silencieusement. Derrière le faux mur, des barils métalliques sont soigneusement alignés. Leur surface est maculée de rouille, témoignant du passage du temps et de la négligence. #didascalie +Leur marquage ne laisse aucun doute quant à leur contenance : des têtes de mort stylisées et la mention "TOXIQUE". Qu'est-ce qu'ils peuvent bien faire là ?! #didascalie +Vous prenez quelques photos avec votre téléphone. Voilà qui va faire changer d'avis les villageois. #didascalie + ~ Topics = ToxicBarrels + +- +->loop + + +=== final_reveal + +L'agitation parcourt la salle lorsque vous répondez d'une voix sonore : #didascalie ++ – Ce sont des fûts de produits toxiques cachés sous l'école. +- + +– Je suis en mesure de tout vous expliquer, déclara solennellement le châtelain. "Il m'était impossible de confier au maire que mes parents, les anciens châtelains, avaient dissimulé des produits toxiques sous l'école. J'ai donc consenti à utiliser l'intégralité de ma fortune pour racheter l'établissement, feignant un projet de transformation en centre aquatique. À l'origine, mon intention était simplement de reconstruire l'école. Malheureusement, le maire n'était pas réceptif à cette idée. C'est alors que j'ai été contraint de jouer de ses instincts les plus sombres pour atteindre mes fins. + +– Tout s'explique ! + +L'assistance se retourne vers la voix venue du dehors. # didascalie + ++ Mais qui vient de parler ? +- + +C'est Ludivine, la Comtesse de Peyrian qui vient d'apparaître par la porte fenêtre. + +– Je suis profondément désolé, ma chérie. Je n'aurais jamais dû t'impliquer dans tout cela", déclare le Châtelain à sa fille avec une lueur de regret dans le regard, c'était un fardeau que je portais seul, et je regrette sincèrement de t'avoir entraînée dans cette affaire. Pardonne-moi. +- ++ Épilogue # title + -> epilogue + +=== epilogue + +Mandonez-Savès, Gers, 16 juillet 1992, 13h + +L'école, désormais débarrassée des fûts toxiques et rénovée grâce aux dons de la Comtesse rouvre ses portes avec une kermesse exceptionnelle. Les rires résonnent à nouveau dans la cour de récréation. + +Dans l'ombre du château qui domine le village, la douceur de vivre reprend ses droits. L'ancien Maire a été arrêté pour corruption et à la terrasse du bar de Virginie, on se demande qui lui succèdera ! + + +->END diff --git a/inkcpp_test/ink/Storylets.ink b/inkcpp_test/ink/Storylets.ink new file mode 100644 index 00000000..8ba2f7e9 --- /dev/null +++ b/inkcpp_test/ink/Storylets.ink @@ -0,0 +1,127 @@ +/* + @title Untitled Story + @author Unknown + @font sans +*/ + +/* "Storylet" implementation. + + Based on https://github.com/smwhr/ink-storylets/tree/main by smwhr. + + This allows you to create content as "storylets" - little chunks / scenes, which are gated by preconditions. The game offers a choice of which one the player can look at, by picking the top N that are available now. + + The machinery is fairly light, and the data is kept "with" the story content, so its easy to use as an extendable template. + +*/ + + + +The beginning! + +- (opts) + // pick top 2 stories to choose from + <- listAvailableStorylets(2, -> opts) + + // provide a fallback if there's nothing available + * -> + +- There was nothing else to do. + -> END + +// The following functions run through the storylet database +// and find acceptable storylets, in priority order + +VAR AvailableStorylets = () + +== listAvailableStorylets(max, -> backTo) + ~ AvailableStorylets = () + ~ computeStorylets(LIST_ALL(Storylets), max) + -> offerStorylets(AvailableStorylets, backTo) + +== function computeStorylets(list, max) + ~ temp current = LIST_MIN(list) + { current && max > 0: + ~ list -= current + ~ temp storyletFunction = StoryletDatabase(current) + + { storyletFunction(Condition): + ~ AvailableStorylets += current + ~ max-- + } + ~ computeStorylets(list, max) + } + +=== offerStorylets(list, -> backTo)=== + ~ temp current = LIST_MIN(list) // in ascending storylet order + {current: + ~ list -= current + ~ temp storyletFunction = StoryletDatabase(current) + + + [{storyletFunction(ChoiceText)}] + ~ temp whereTo = storyletFunction(Content) + -> whereTo -> backTo + } + { list: + -> offerStorylets(list, backTo) + } + -> DONE + + +// The database, linking storylet LIST values to their index functions + +LIST Props = Content, Condition, ChoiceText + +LIST Storylets = StoryA, StoryB, StoryC // in priority order + +=== function StoryletDatabase(storylet) + { storylet: + - StoryA: ~ return -> StoryletData_Avocado + - StoryB: ~ return -> StoryletData_Bananas + - StoryC: ~ return -> StoryletData_Crumpets + } + +// Story Content: each storylet is a database function / content pair. + +=== function StoryletData_Avocado(prop) + { prop: + - ChoiceText: Visit the Avocado Witch + - Condition: ~ return not witch_content // once only + - Content: ~ return -> witch_content + } + +=== witch_content + You visit the witch. + ->-> + + + +=== function StoryletData_Bananas(prop) + { prop: + - ChoiceText: Now you have met the King, visit the Banana Boy! + - Condition: ~ return not boy_content && king_content // once only + - Content: ~ return -> boy_content + } + +=== boy_content + You visit the boy. + ->-> + + +=== function StoryletData_Crumpets(prop) + { prop: + - ChoiceText: Visit the Crumpet King + - Condition: ~ return not king_content // once only + - Content: ~ return -> king_content + } + +=== king_content + You visit the king. + ->-> + +Once upon a time... + + * There were two choices. + * There were four lines of content. + +- They lived happily ever after. + -> END diff --git a/inkcpp_test/ink/case1_repro.ink b/inkcpp_test/ink/case1_repro.ink new file mode 100644 index 00000000..4f77b673 --- /dev/null +++ b/inkcpp_test/ink/case1_repro.ink @@ -0,0 +1,5 @@ +{Le soleil brille sur la place. #CLASS: lettrine |{ shuffle: + - Le soleil baigne la petite place. + - Les platanes offrent un peu d'ombre. + }} +-> END diff --git a/inkcpp_test/ink/case2_repro.ink b/inkcpp_test/ink/case2_repro.ink new file mode 100644 index 00000000..3ce0852b --- /dev/null +++ b/inkcpp_test/ink/case2_repro.ink @@ -0,0 +1,20 @@ +LIST Items = A, B + +-> main + +=== main +~ temp Exits = (B) +<- listExits(Exits, -> after) +-> DONE + +=== after +Arrived. +-> END + +=== listExits(list, ->next) + ~ temp current_ctc = LIST_MIN(list) + { LIST_COUNT(list) > 0 : + <- listExits(list - current_ctc, next) + + [Vers {current_ctc}... #exit] + -> next + } diff --git a/inkcpp_test/ink/case3_repro.ink b/inkcpp_test/ink/case3_repro.ink new file mode 100644 index 00000000..5d95ac63 --- /dev/null +++ b/inkcpp_test/ink/case3_repro.ink @@ -0,0 +1,9 @@ +VAR x = "" +VAR cond = true +~ x = "prefix {cond: +line one +- else: +line two +} suffix" +{x} +-> END diff --git a/inkcpp_test/ink/case4_repro.ink b/inkcpp_test/ink/case4_repro.ink new file mode 100644 index 00000000..13760c10 --- /dev/null +++ b/inkcpp_test/ink/case4_repro.ink @@ -0,0 +1,15 @@ +-> knot + +=== knot === +* [Choice one # a1 # a2 # a3 # a4] + -> END +* [Choice two # b1 # b2 # b3 # b4] + -> END +* [Choice three # c1 # c2 # c3 # c4] + -> END +* [Choice four # d1 # d2 # d3 # d4] + -> END +* [Choice five # e1 # e2 # e3 # e4] + -> END +* [Choice six # f1 # f2 # f3 # f4] + -> END diff --git a/inkcpp_test/ink/case5_repro.ink b/inkcpp_test/ink/case5_repro.ink new file mode 100644 index 00000000..f1fe350b --- /dev/null +++ b/inkcpp_test/ink/case5_repro.ink @@ -0,0 +1,14 @@ +VAR x = "" +VAR i = 0 +-> loop + +=== loop === +{ i >= 4: -> done } +~ i = i + 1 +~ x = "prefix {cycle:AAAA|BBBB|CCCC} suffix" +{x} +-> loop + +=== done === +After line. +-> END diff --git a/inkcpp_test/ink/case6_repro.ink b/inkcpp_test/ink/case6_repro.ink new file mode 100644 index 00000000..e0461e2d --- /dev/null +++ b/inkcpp_test/ink/case6_repro.ink @@ -0,0 +1,22 @@ +-> title + +=== title ++ Play + -> act1 -> playloop + +=== act1 +-> act_1_sc_1 -> +->-> + +=== act_1_sc_1 + +* Express Joy +* Express Sadness + +- +->-> + +=== playloop + +The PLAYLOOP +->END diff --git a/inkcpp_test/ink/includes/verbose.ink b/inkcpp_test/ink/includes/verbose.ink new file mode 100644 index 00000000..d03e5ab2 --- /dev/null +++ b/inkcpp_test/ink/includes/verbose.ink @@ -0,0 +1,86 @@ +=== function came_from(-> x) + ~ return TURNS_SINCE(x) == 0 + +=== function seen_recently(-> x) + ~ return TURNS_SINCE(x) >= 0 && TURNS_SINCE(x) <= 1 + + +=== function listWithCommasAnd(list, if_empty) + {LIST_COUNT(list): + - 2: + {verbose(LIST_MIN(list))} et {listWithCommasAnd(list - LIST_MIN(list), if_empty)} + - 1: + {verbose(list)} + - 0: + {if_empty} + - else: + {verbose(LIST_MIN(list))}, {listWithCommasAnd(list - LIST_MIN(list), if_empty)} + } + +=== function is_are(list) + {LIST_COUNT(list): + - 1 : est + - else : sont + } + +=== function toward(term) +{term: + - Mairie: {PreviousLocation: + - BureauMaire: sortez dans le hall de + - else: entrez dans + } + - BureauMaire: entrez dans + - PlaceMairie: retournez vers + - Eglise: entrez dans + - Bar: entrez dans + - AncienClocher: suivez les indications fournies par le prêtre et parvenez jusqu'à + - Route: empruntez + - Ecole: entrez dans + - Chateau: suivez les indications inscrites sur le carton d'invitation et parvenez devant + - BalServeuse: approchez + - BalTenor: approchez + - BalPretre: approchez + - BalMaire: approchez + - BalChatelain: approchez + - SalleBal: {BalLocations ? PreviousLocation: + - retournez au centre de + - else: entrez dans + } + - BureauChatelain: entrez dans + - else: vous dirigez vers +} + + +=== function verbose(term) +{term: + + // Locations + - Me: Vous + - Mairie: {CurrentLocation == BureauMaire: le hall de }la Mairie + - BureauMaire: le Bureau du Maire + - PlaceMairie: la place du village + - Eglise: l'église + - Ecole: l'école + - Bar: le bar + - AncienClocher: l'ancien clocher + - Chateau: le Chateau de Peyrian + - Route: la route bordée de platanes + - SalleBal: {BalLocations ? CurrentLocation: le centre de }la salle de bal + - Vestibule: le hall d'entrée du chateau + - PlacardElectrique: le placard électrique + - BalServeuse: la serveuse + - BalTenor: le ténor + - BalPretre: le prêtre + - BalMaire: le maire + - BalChatelain: le Châtelain + - BureauChatelain: le bureau du Châtelain + + // Characters + - Maire: Le Maire + - Comtesse: {Une jeune femme blonde qui semble attendre quelque chose|La jeune femme blonde} + - Pretre: Le prêtre + - Barmaid: {Characters !? Barmaid : Une jeune serveuse blonde|Virginie la serveuse} + - Tenor: {Characters !? Tenor : Un homme d'une cinquantaine d'années|Roberto le Ténor} + - Chatelain: Le Châtelain + - else: {term} +} diff --git a/inkcpp_test/ink/story/conversation.ink b/inkcpp_test/ink/story/conversation.ink new file mode 100644 index 00000000..fb2d1f19 --- /dev/null +++ b/inkcpp_test/ink/story/conversation.ink @@ -0,0 +1,238 @@ +== ConversationMaire(->flow, ->trigger) +VAR vuTiroir = false +VAR knowMaireHasCalisson = false +-(talk_to_maire) + +*{Topics ? DisparitionComtesse} (au maire) – Savez-vous où est la Comtesse ? + – Ne m'en parlez pas ! Ça fait 48h qu'elle a disparu alors que ce soir une soirée est donnée en son honneur au Chateau de Peyrian ! + ->trigger + +*{Topics ? OuEstVieuxClocher}(au maire) – Savez-vous où est le Vieux Clocher ? + – Je n'en ai pas la moindre idée... + ->trigger + +* {vuTiroir and LocationOf(Me) == BureauMaire} (au maire) – Qu'est-ce que vous mangez ? + – Ce sont des calissons au Melon de Cadour, dîtes mois si vous voulez gouter ! + ~ knowMaireHasCalisson = true + ->trigger + +* {knowMaireHasCalisson} (au maire) – Je veux bien gouter un de vos calissons, finalement ! + – Avez plaisir ! Tenez, je vous en donne même une petite boîte. Ça m'évitera de me goinfrer. + Le Maire sort une petite boîte en métal en forme losange de {LocationOf(Maire) == BureauMaire: son tiroir|sa poche} et vous la tend. #didascalie + ~hasCalissons = true + -> trigger + +* {Characters ? Comtesse} (au maire) – J'ai retrouvé la Comtesse [!] , elle est au Vieux Clocher ! + – Quoi ! Attendez moi dehors, j'y cours ! + Sans plus attendre, le Maire vous fait sortir de son bureau et s'éloigne. #didascalie + Vous êtes dans le hall de la Mairie. La porte du bureau du Maire est entrouverte, dans la précipitation il a oublié de la verrouiller. #didascalie + ~MoveTo(Me, Mairie) + ~MoveTo(Maire, AncienClocher) + ->trigger + +* {Topics ? (CentreAquatique, InondationSuspecte)} (au maire) – Je sais ce que vous avez fait ! + -> confront_maire + +* {Topics ? CentreAquatique and Moment == Soir} – Je vais tout révéler ! + – Faites faites, je suis déjà passé avant vous et, l'idée est plutôt reçue positivement ! + ->trigger + +* {Topics ? ToxicBarrels} – Et que dîtes-vous de ça Monsieur de le Maire ? + – Mais qu'est-ce que ... ? + ->final_reveal + +- +->flow +->DONE + + +== ConversationComtesse(->flow, ->trigger) + +*\ (à la jeune femme blonde) – Comtesse ? + -> meet_the_comtesse +*\ (à la jeune femme blonde) – Ludivine ? + -> meet_the_comtesse +->DONE + + +== ConversationPretre(->flow, ->trigger) + +~Topics -= OuEstPretre + +~temp CanTalkToPretre = (LocationOf(Me) == Bar and offered_calissons) or (LocationOf(Me) != Bar) + + +*(offered_calissons){hasCalissons and ConversationBarmaid.ask_appeler}(au prêtre) – Mon père, j'ai ici quelques calissons... + L'homme se tourne vers vous et attrape la petite boîte losange que vous lui tendez. Il a le sourire d'un enfant. # didascalie + ~Topics -= ParlerPretre + ->trigger + + ++{not CanTalkToPretre} (au prêtre) – Excusez-moi mon père ? + {L'homme détourne la tête et d'un geste de la main vous signifie que vous l'importunez. |– Inutile d'insister, j'aime être seul|–Cessez !|L'homme vous ignore complètement.} #didascalie + ~Topics += ParlerPretre + ->trigger + +*{CanTalkToPretre and (Topics ? OuEstVieuxClocher)}(au prêtre) – Vous pourriez m'indiquer où se trouve le Vieux Clocher ? + – Bien sûr, votre générosité mérite récompense, ainsi que le dit la Parole de Dieu notre Seigneur. + Il sort un calepin de sa soutane et au crayon griffone un plan. #didascalie + ~knowRouteToAncienClocher = true + – Prenez la route qui sort du village, celle qui est bordée de platanes. Et avec ceci vous devriez trouver votre chemin sans problème. Maintenant, si vous le voulez bien, j'ai à faire. + Le prêtre sort. #didascalie + ~MoveTo(Pretre, Eglise) + ~Topics -= OuEstVieuxClocher + ->trigger + +* {Topics ? CentreAquatique and Moment == Soir} – Que pensez-vous du projet de centre aquatique ? + – Si l'école publique ferme, je réfléchirai à ouvrir une école privée dans le presbytère. C'est du travail, mais c'est important que les petits du village puisse avoir une éducation près de chez eux. + ->trigger + +* {Topics ? ToxicBarrels} – Regardez ces photos que ce je viens de prendre Monsieur le Curé... + – Bonté divine, qu'est-ce donc ? + ->final_reveal + +- +->flow +->DONE + + +== ConversationBarmaid(->flow, ->trigger) + +VAR servi = false + ++(ask_servir){Characters hasnt Barmaid} (à la serveuse) – Qui êtes vous ? + – Moi c'est Virginie, je peux vous servir quelque chose ? + ~Characters += Barmaid + ->trigger + ++{seen_recently(->ask_servir) and not servi} (à la serveuse) – Volontiers, un pastis s'il vous plaît ? + – Vous venez d'ailleurs vous pour demander un pastis. Mais j'ai ça. Voilà pour vous. + ~servi = true + ->trigger + ++{ask_servir and not servi} (à la serveuse) – Ça ira... Un verre d'eau peut-être [?] il fait chaud dehors + – C'est sûr qu'il y a un sacré cagnard. Bah hésitez pas si vous avez besoin de quelqu'chose ! + ~servi = true + ->trigger + +*{Topics ? OuEstVieuxClocher} (à la serveuse) – Vous sauriez où est le Vieux Clocher ? + – Je m'intéresse pas trop à ces vieilleries moi. C'est pas l'Église ? Elle est assez vieille. Attendez, le vieux Prêtre il doit savoir aussi. + ~Topics+=OuEstPretre + ->trigger + +*(ask_appeler){Topics ? OuEstPretre} (à la serveuse) – Vous sauriez où est le Prêtre ? + – Il est souvent là c'est vrai ! Mais là non. Je lui passe un coup de fil, il sera là dans un moment. Par contre, quand il est ici, il aime pas parler, donc préparez une contrepartie ! + ->trigger + ++{Topics ? ParlerPretre and Topics !? Calissons} (à la serveuse) – Le prêtre ne veut pas me parler... + – Je vous l'ai dit, quand il est ici, il ne veut parler à personne sans une bonne raison ! + -> trigger + ++{Topics ? ParlerPretre and Topics ? Calissons} (à la serveuse) – Vous pensez que des calissons[...] seraient une bonne raison de me parler ? + – Je ne sais pas, je ne connais pas ses goûts ! + {LocationOf(Pretre) == LocationOf(Barmaid): + - Du coin de l'œil vous sentez que vous avez brièvement capté l'attention du prêtre. #didascalie + } + + -> trigger + +*{Topics ? Inondation} (à la serveuse) – Vous avez entendu parler de l'inondation de l'école ? + – C'est arrivé la semaine passée oui. J'étais chez ma mère avec ma fille. Quand je suis revenue, ils nous ont dit que le centre de loisirs était fermé, alors j'ai renvoyé la petite chez sa grand-mère. + ->trigger +*{Topics ? Inondation and knowArriereBar and Locations !? ArriereBar} (à la serveuse) – Votre arrière boutique donne sur le préau de l'école ? + – Tout à fait. Vous voulez jeter un œil ? + La serveuse vous fait passer derrière le bar et ouvre une porte vers l'extérieur. #discalie + ~ MoveTo(Me, ArriereBar) + ~ Locations += ArriereBar + ~ MoveTo(Barmaid, ArriereBar) + ~ describe(ArriereBar) + ->trigger + ++{Topics ? Inondation and knowArriereBar and Locations ? ArriereBar and LocationOf(Barmaid) == Bar} (à la serveuse) – Ça vous ennuie si je retourne voir l'arrière boutique ? + – Pas de souci, allez-y. + ~ MoveTo(Me, ArriereBar) + ~ describe(ArriereBar) + ->trigger + +* {Topics ? CentreAquatique and Moment == Soir} – Vous avez entendu parler du projet de centre aquatique ? + – Le Maire m'en parlait il y a quelques minutes, c'est une super initiave. C'est vrai que ça va me compliquer un peu la vie avec la petite, l'école sera plus à côté, mais ça va me rapporter plein de clients ! + ->trigger +* {Topics ? InondationSuspecte and Moment == Soir} – Vous ne trouvez pas ça bizarre que votre bar n'ait pas été inondé ? + – La météo moi j'y comprends rien de toute manière ! + ->trigger + + +* {Topics ? ToxicBarrels} – Regardez ce que j'ai trouvé dans l'école ? + – Boudu mais ce sont ... ? + ->final_reveal + +- +->flow +->DONE + + + +=== ConversationTenor(->flow, ->trigger) + +~temp adresseTenor = "{Characters has Tenor:au ténor|à l'homme}" + +*(vieuxclocher1) {Topics ? OuEstVieuxClocher and LocationOf(Me) == Eglise} ({adresseTenor}) – C'est bien ici le Vieux Clocher ? + – Ah non du tout ! + ->trigger + +*(vieuxclocher2) {Topics ? OuEstVieuxClocher and vieuxclocher1} ({adresseTenor}) – Et vous sauriez où est le Vieux Clocher ? + – Moi non, mais vous pourriez demander au Prêtre, il en sait en rayon sur le coin, c'est le plus vieil habitant du village. + ~Topics+=OuEstPretre + ->trigger + +*{Topics ? OuEstPretre and not ConversationBarmaid.ask_appeler} ({adresseTenor}) – Et vous sauriez où est le Prêtre ? + – Justement, ça fait bientôt une heure que je l'attends. Il doit être en train de boire un coup au bar. + ->trigger + ++(related_to_pretre){Characters hasnt Tenor} ({adresseTenor}) – Qui êtes vous ? + – Je m'appelle Roberto, j'anime la chorale de l'Église pour Monsieur le Curé et je donne des cours de chant à l'école. Mais je suis avant tout ténor à l'Opéra de Toulouse. + ~Characters += Tenor + ->trigger + ++{related_to_pretre and Topics ? ParlerPretre} ({adresseTenor}) – Si vous cotoyez souvent Monsieur le Curé [...], vous savez peut-être ce qui pourrait lui délier la langue ? + – Haha, il ne veut pas vous parler ? Apportez-lui des calissons, c'est son pêché mignon ! + ~Topics += Calissons + ->trigger + +*{related_to_pretre and Topics ? ComplotEcole and not triedGrille} ({adresseTenor}) – Vous avez mentionné que vous enseignez à l'école [...] ? + — Oui mais elle est fermée pour le moment, il y a eu une sacrée inondation. + ~ Topics += Inondation + ->trigger + +*{related_to_pretre and Topics ? ComplotEcole and triedGrille} ({adresseTenor}) – Avez-vous un moyen d'accéder à l'école [?], la grille semble fermée. + – Bien sûr, j'ai la clé ! Je peux vous ouvrir si vous voulez, rejoignez moi là-bas. + Roberto le Ténor se lève et quitte le bar. #didascalie + ~ MoveTo(Tenor, Mairie) + ->trigger + +* {Topics ? CentreAquatique and Moment == Soir} – Vous avez entendu parler du projet de centre aquatique ? + – Je viens de l'apprendre. C'est terrible cette destruction de l'école... J'aimais tant leur apprendre le chant à ces petits chérubins ! + ->trigger +* {Topics ? InondationSuspecte and Moment == Soir} – L'inondation de l'école me paraît très suspecte... + – Oh ! Si vous avez besoin de moi, n'hésitez pas ! En attendant, je prépare mon tour de magie. + ~Topics+=MagicTrick + ->trigger +*(disparition) {Topics ? MagicTrick and Moment == Soir} – Vous savez faire de la magie ? + – Ma spécialité, ce sont les disparitions d'objets ! + ->trigger + +* (promesse){Topics ? MagicTrick and Topics ? CleChatelain and disparition and Moment == Soir} – Vous sauriez subtiliser la clé que le Châtelain a autour du cou ? + – Pas en pleine lumière, mais si vous trouvez le moyen de nous plonger brièvement dans l'obscurité, je vous récupère ça ! + ->trigger + +- +->flow +->DONE + + +== ConversationChatelain(->flow, ->trigger) +* {Topics ? ToxicBarrels} – Monsieur le Châtelain, qu'est-ce que vous cachez ? + – De quoi parlez-vous ? + – De ce que je viens de trouver caché dans l'école... + ->final_reveal +->DONE diff --git a/inkcpp_test/ink/story/investigation.ink b/inkcpp_test/ink/story/investigation.ink new file mode 100644 index 00000000..f952f0fb --- /dev/null +++ b/inkcpp_test/ink/story/investigation.ink @@ -0,0 +1,79 @@ +=== InvestigateBureauMaire(->flow, ->trigger) +* {LocationOf(Maire) != BureauMaire} [Ouvrir le tiroir # investigate] Vous ouvrez le tiroir #didascalie. + Vous trouvez une demi-douzaine de boîtes de calissons. #didascalie + ->trigger + ++ {Topics ? ComplotEcole and LocationOf(Maire) == BureauMaire} [Ouvrir l'armoire d'archives # investigate] + Vous ne pouvez pas inspecter l'armoire pendant que le Maire est là. #didascalie + ->trigger + +* {Topics ? ComplotEcole and LocationOf(Maire) != BureauMaire} [Ouvrir l'armoire d'archives # investigate] + Vous profitez de l'absence du Maire pour fouiller l'armoire d'archives. #didascalie + Un document en particulier attire votre attention : un permis de démolir l'école de Mandonez pour faire construire au même emplacement un centre de loisirs aquatiques ! #didascalie + ~Topics += CentreAquatique + ->trigger + +- +->flow +->DONE + + +=== InvestigateEcole(->flow, ->trigger) +* {Topics ? ComplotEcole} [Observer l'affaissement # investigate] + De l'autre côté de ce qui était un préau, vous apercevez la Saves, la petite rivière qui longe Mandonez et qui lui donne son qualificatif, "Savès". # didascalie + Le cours d'eau est réduit à son strict minimum, pas étonnant en période de sécheresse. #didascalie + Dans le prolongement du préau, derrière un grillage, vous apercevez des fûts de bière, ce doit être l'arrière boutique du bar. #didascalie + ~knowArriereBar = true + -> trigger +- +->flow +->DONE + + +=== InvestigateArriereBar(->flow, ->trigger) +* {Topics ? Inondation} [Observer l'affaissement de l'école # investigate] + La différence est saisissante : là où l'école semble avoir été frappée par un cataclysme, avec des dégâts considérables, le bar lui semble avoir été épargné alors que seuls quelques mètres les séparent. #didascalie + ~Topics -= Inondation + ~Topics += InondationSuspecte + + -> trigger +- +->flow +->DONE + +=== InvestigatePlacardElectrique(->flow, ->trigger) ++(cut_light) [Couper le disjoncteur général #investigate] + Vous pressez {|à nouveau }l'interrupteur général du tableau électrique. Tout le chateau est plongé dans l'obscurité. # didascalie + Des cris retentissent dans la salle de bal. # didascalie + Vous entendez des pas qui s'approchent, vous remettez en route la lumière et vous faufilez dans le vestibule. # didascalie + ~MoveTo(Me, Vestibule) + {ConversationTenor.promesse: + Vous espérez que Roberto a eu le temps de faire son "tour de magie". #didascalie + ~stolenCleChatelain = true + } + -> trigger + +- +->flow +->DONE + +=== InvestigateBureauChatelain(->flow, ->trigger) + +* [Observer le plan # investigate] + Vous observez le plan, probablement celui du nouveau centre aquatique? #didascalie + Pas du tout, il s'agit manifestement d'un plan pour une nouvelle école ! On voit nettement la forme des salles de classe. #didascalie + ->trigger + +* [Fouiller parmi les documents # investigate] + Parmi les documents se trouve la correspondance avec le Maire. Il y a ici assez pour incriminer ce dernier et le Châtelain. #didascalie + ->trigger + +* [Chercher dans l'armoire # investigate] + Au milieu des documents comptables, un bip électronique. Il est un peu sale. Une diode rouge indique qu'il est inactif. #didascalie + ~BipToxicBarrels = true + -> trigger + + +- +->flow +->DONE diff --git a/inkcpp_test/ink/story/triggers.ink b/inkcpp_test/ink/story/triggers.ink new file mode 100644 index 00000000..0299c160 --- /dev/null +++ b/inkcpp_test/ink/story/triggers.ink @@ -0,0 +1,110 @@ +VAR EcoleOpen = false +VAR triedGrille = false +VAR ChatelainOpen = false +VAR BipToxicBarrels = false + +=== triggers + +{TURNS_SINCE(->ConversationBarmaid.ask_appeler): +- 4 : + ~MoveTo(Pretre, Bar) + {LocationOf(Me) == Bar: Un prêtre en soutane entre dans le bar et s'installe à ce qui semble être sa place habituelle. #didascalie} +} +{ConversationMaire.talk_to_maire >= 2 and LocationOf(Me) == LocationOf(Maire) and LocationOf(Me) == BureauMaire and not vuTiroir: + - Le Maire recule son fauteuil, farfouille dans un tiroir de son bureau et attrape quelque chose qu'il porte à sa bouche. #didascalie + ~vuTiroir = true +} + +{ConversationMaire.talk_to_maire == 5 and hasCalissons and Characters !? Comtesse: + - {LocationOf(Me) == BureauMaire: + - Le Maire se lève pour vous signaler que c'est la fin de cet entretien et vous raccompagne dans le vestibule de la Mairie. + ~MoveTo(Me, Mairie) + + } + {LocationOf(Maire) == BureauMaire: + ~MoveTo(Maire, Chateau) + } +} + +{LocationOf(Me) == Ecole and not EcoleOpen: + - ~MoveTo(Me, Mairie) + Vous ne pouvez pas, la grille est fermée. + ~triedGrille = true + -> loop +} + +{Topics ? (CentreAquatique, InondationSuspecte): + -{once: + - Avec vos suspicions sur la nature de l'inondation de l'école et le document trouvé dans les archives du Maire, vous avez assez d'éléments pour tenter une confrontation avec ce dernier. # didascalie + ~ MoveTo(Maire, BureauMaire) + {LocationOf(Me) == BureauMaire: + - C'est justement le moment que choisit le Maire pour entrer dans son bureau ! #didascalie + } + } +} + +{ (Bar, Eglise) ? LocationOf(Me) and Moment == Soir: + - Vous ne pouvez pas, {verbose(LocationOf(Me))} est {fermé|verrouillé|inaccessible} pour la soirée. + ~MoveTo(Me, PlaceMairie) + -> loop +} + +{LocationOf(Me) == BureauChatelain and not ChatelainOpen: + - ~MoveTo(Me, Vestibule) + Vous ne pouvez pas entrer, la porte est fermée. + -> loop +} + + +- +->-> + +=== triggers_aftermove + +{LocationOf(Me) == Mairie and LocationOf(Tenor) == Mairie and not EcoleOpen: + - + – Je vous ouvre, déclare Roberto, {Topics ? Inondation: comme je vous ai dit, il y a eu une sacrée inondation, donc faites attention|mais faites attention, l'école a subit une inondation}. + ~Topics += Inondation + ~EcoleOpen = true + La grille qui donne sur la cour de l'école grince sur ses gonds. # didascalie + – Je vous fais confiance pour refermer, je dois vous laisser je dois me préparer pour le bal de ce soir. + Roberto le ténor s'éloigne. #didascalie + ~MoveTo(Tenor, SalleBal) +} + +{LocationOf(Me) == Bar and PreviousLocation == ArriereBar: + - { LocationOf(Barmaid) == ArriereBar: + - La serveuse vous suit et referme la porte derrière vous. #didascalie + - else: La serveuse vous fait signe de fermer la porte. #didascalie + } + ~ MoveTo(Barmaid, Bar) +} + +VAR stolenCleChatelain = false +{LocationOf(Me) == LocationOf(Chatelain): + {not stolenCleChatelain: + - + \ Vous remarquez une clé autour du cou du Châtelain. #didascalie + ~ Topics += CleChatelain + -else: + \ Vous remarquez que la clé a disparu du cou du Châtelain. # didascalie + } +} + +{LocationOf(Me) == LocationOf(Tenor) and stolenCleChatelain: + \ Roberto vous tend la clé. #didascalie + – Faites ce que vous avez à faire, je m'éclipse ! + ~ BalLocations -= BalTenor + ~MoveTo(Tenor, NoWhere) + ~MoveTo(Me, SalleBal) + ~ChatelainOpen = true +} + +{LocationOf(Me) == Ecole and BipToxicBarrels: + Dans votre poche le bip trouvé dans le bureau du Châtelain se met à vibrer. + * Déclencher le bip #investigate + -> ouverture_caveau +} + +- +->-> diff --git a/inkcpp_test/ink/world/characters.ink b/inkcpp_test/ink/world/characters.ink new file mode 100644 index 00000000..3d963590 --- /dev/null +++ b/inkcpp_test/ink/world/characters.ink @@ -0,0 +1,71 @@ +LIST Characters = (Me), Maire, Comtesse, Pretre, Barmaid, Tenor, Chatelain + +VAR LocMaire = (BureauMaire) +VAR LocComtesse = (AncienClocher) +VAR LocPretre = () +VAR LocBarmaid = (Bar) +VAR LocTenor = (Eglise) +VAR LocChatelain = (SalleBal) + +=== function LocationOf(Character) +{Character: +- Me: + ~return CurrentLocation +- Maire: + ~return LocMaire +- Comtesse: + ~return LocComtesse +- Pretre: + ~return LocPretre +- Barmaid: + ~return LocBarmaid +- Tenor: + ~return LocTenor +- Chatelain: + ~return LocChatelain +} + +=== function MoveTo(Character, Location) +{Character: +- Me: + ~ PreviousLocation = CurrentLocation + ~ CurrentLocation = Location +- Maire: + ~ LocMaire = Location +- Comtesse: + ~ LocComtesse = Location +- Pretre: + ~ LocPretre = Location +- Barmaid: + ~ LocBarmaid = Location +- Tenor: + ~ LocTenor = Location +- Chatelain: + ~ LocChatelain = Location +} + +=== function CharactersIn(Location) +~temp characters = () +{Location ? LocationOf(Me): + ~characters+=Me +} +{Location ? LocationOf(Maire): + ~characters+=Maire +} +{Location ? LocationOf(Comtesse): + ~characters+=Comtesse +} +{Location ? LocationOf(Pretre): + ~characters+=Pretre +} +{Location ? LocationOf(Barmaid): + ~characters+=Barmaid +} +{Location ? LocationOf(Tenor): + ~characters+=Tenor +} +{Location ? LocationOf(Chatelain): + ~characters+=Chatelain +} +~return characters + diff --git a/inkcpp_test/ink/world/locations.ink b/inkcpp_test/ink/world/locations.ink new file mode 100644 index 00000000..c46bd635 --- /dev/null +++ b/inkcpp_test/ink/world/locations.ink @@ -0,0 +1,112 @@ +VAR PreviousLocation = () +VAR CurrentLocation = () + +LIST Locations = Bus, PlaceMairie, Mairie, BureauMaire, Ecole, Eglise, Bar, ArriereBar, AncienClocher, Route, Chateau, PlacardElectrique, SalleBal, BalServeuse, BalTenor, BalPretre, BalMaire, BalChatelain, BureauChatelain, Vestibule, NoWhere + +VAR knowRouteToAncienClocher = false +VAR knowRouteToChateau = false +VAR knowArriereBar = false + +LIST Moment = Jour, Soir +~ Moment = Jour + +VAR BalLocations = (BalServeuse, BalTenor, BalPretre, BalMaire, BalChatelain) + +=== exits(room, ->next) + +~temp Exits = () +{room: + - PlaceMairie: + ~ Exits = (Mairie, Eglise, Bar, Route) + - Mairie: + ~ Exits = (PlaceMairie, Ecole) + {LocationOf(Maire) == BureauMaire or LocationOf(Maire) == AncienClocher: + - ~Exits += (BureauMaire) + } + - BureauMaire: + ~ Exits = (Mairie) + - Eglise: + ~ Exits = (PlaceMairie) + - Ecole: + ~ Exits = (Mairie, PlaceMairie) + - Bar: + ~ Exits = (PlaceMairie) + - ArriereBar: + ~ Exits = (Bar) + - Route: + ~ Exits = (PlaceMairie) + {knowRouteToAncienClocher: + ~Exits += (AncienClocher) + } + {knowRouteToChateau: + ~Exits += (Chateau) + } + - AncienClocher: + ~ Exits = (PlaceMairie) + - Chateau: + ~ Exits = (PlaceMairie, SalleBal) + - SalleBal: + ~ Exits = BalLocations + (Vestibule) + {LocationOf(Chatelain) != BalChatelain: + ~ Exits -= BalChatelain + } + -BalServeuse: + ~ Exits = (SalleBal) + - BalTenor: + ~ Exits = (SalleBal) + - BalPretre: + ~ Exits = (SalleBal) + - BalMaire: + ~ Exits = (SalleBal) + - BalChatelain: + ~ Exits = (SalleBal) + - Vestibule: + ~ Exits = (SalleBal, BureauChatelain, PlacardElectrique, PlaceMairie) + - PlacardElectrique: + ~ Exits = (Vestibule) + - BureauChatelain: + ~ Exits = (Vestibule) + +} +<-listExits(Exits, next) +->DONE + + +=== function describe(room) + +{room: + - PlaceMairie: {Moment: + -Jour:{<> Le soleil écrasant plonge Mandonez sur Saves dans la torpeur estivale. Les rayons ardents filtrent à travers les feuillages des vieux platanes. Les motifs dansants de lumière et d'ombre se projettent sur le sol poussiéreux. Les maisons de pierre aux volets colorés bordent la place. Faute de vent, le drapeau tricolore pend tristement sur la Mairie de briques roses. Quelques tables en fer forgé sont sorties devant un bar dont l'enseigne est illisible. Par delà les platanes, le clocher de l'église domine l'horizon sous le ciel azur. #CLASS: lettrine |<> { shuffle: + - Le soleil écrasant baigne la petite place de Mandonez. + - Les platanes au feuillage épais offrent un peu de répis face à la chaleur de plomb. + - Le clocher de l'église domine l'horizon sous le ciel azur. + - Les façades de pierre aux volets colorés bordent la place. + - Sous le soleil brûlant, la place semble déserte et paisible. + }}# didascalie + -Soir:{<> À Mandonez, le soleil s'est couché, plongeant le village dans la quiétude. Les silhouettes sombres des platanes bruissent sous la brise du soir. Le clocher de l'église se découpe nettement contre le ciel étoilé. #CLASS: lettrine|<> { shuffle: + - Le clocher de l'église se découpe nettement contre le ciel étoilé. + - Une douce brise nocturne caresse les feuilles des arbres endormis. + }} #didascalie + } + - Mairie: {<> Il n'y a personne dans la salle qui sert de hall. Un guéridon est disposé dans un coin où traînent de vieux prospectus jaunis. #CLASS: lettrine | }À gauche, une plaque indique le bureau du Maire. À droite, une grille en fer forgé donne sur {ce qui semble être une|la} cour de l'école.{LocationOf(Maire) == BureauMaire or LocationOf(Maire) == AncienClocher: La porte du bureau du Maire est entrouverte{LocationOf(Maire) == AncienClocher:, dans la précipitation il a oublié de la verrouiller}.| La porte du bureau du Maire est fermée.} # didascalie + - Eglise: {<> Le grand clocher de l'église domine le village. #CLASS: lettrine |<>} Une fois à l'intérieur vos yeux mettent quelques secondes à s'habituer à l'obscurité.# didascalie + - Bar: {<> #CLASS: lettrine|}# didascalie + - Route: {not knowRouteToAncienClocher: Vous n'avez pas envie de vous aventurer en dehors de la ville pour le moment}<> #CLASS: lettrine # didascalie + - AncienClocher: <> #CLASS: lettrine # didascalie + - Ecole: {<> Les signes de l'inondation dans l'école sont évident. Les murs sont marqués par l'humidité, le sol est recouvert de débris, et les salles de classe sont en désordre avec des fournitures éparpillées.| <> Le sol est boueux.} La structure menace de s'effondrer, s'aventurer plus loin pourrait s'avérer dangereux. #didascalie + - ArriereBar: L'arrière boutique du bar est une terrasse en bois semi-ouverte vers l'extérieur dont les pilotis trempent dans le cours d'eau. Quelques fûts de bière attendent d'être consommés. #didascalie + - Chateau: {<> Le château de Peyrian se dresse sur une colline, majestueux et ancien. Ses murs de pierre et ses tourelles témoignent de son passé riche et ancien. Entouré de jardins soignés, il respire l'élégance et le charme.|} Le portail est ouvert vers la salle de bal. #didascalie + - BureauChatelain: <> Il y a des documents sur le bureau du châtelain, ainsi qu'un plan affiché au mur. Une armoire est entrouverte. #didascalie +} + +=== listExits(list, ->next) + ~ temp current_ctc = LIST_MIN(list) + + { LIST_COUNT(list) > 0 : + <- listExits(list - current_ctc, next) + + [Vers {verbose(current_ctc)}... #exit] + ~PreviousLocation = CurrentLocation + ~MoveTo(Me, current_ctc) + -> next + + } diff --git a/inkcpp_test/ink/world/topics.ink b/inkcpp_test/ink/world/topics.ink new file mode 100644 index 00000000..bb85715a --- /dev/null +++ b/inkcpp_test/ink/world/topics.ink @@ -0,0 +1,4 @@ +LIST Topics = (DisparitionComtesse), (OuEstVieuxClocher), OuEstPretre, ParlerPretre, Calissons, ComplotEcole, CentreAquatique, Inondation, InondationSuspecte, CleChatelain, MagicTrick, ToxicBarrels + + +VAR hasCalissons = false