From 7655378c2616fb807aeaf9c4d183748f3ce2bdaa Mon Sep 17 00:00:00 2001 From: Mikhail Dektyarev Date: Wed, 11 Nov 2026 18:05:35 +0100 Subject: [PATCH] add options needed for better ui to engine --- CLAUDE.md | 4 +- ai/models/ladder_net.py | 4 +- bindings/AGENTS.md | 13 ++- bindings/CMakeLists.txt | 2 + bindings/rollout_batch.hpp | 1 + bindings/state_json.cpp | 35 +++++++ bindings/ts_bindings.cpp | 2 + bindings/ts_engine/__init__.pyi | 10 +- bindings/wasm/ts_engine_wasm.cpp | 131 ++++++++++++++++++++++++-- engine/AGENTS.md | 27 +++++- engine/include/ts/game_state.hpp | 22 ++++- engine/include/ts/state_machine.hpp | 8 ++ engine/src/action_mask.cpp | 6 +- engine/src/card_dispatcher.cpp | 10 ++ engine/src/events/early_war.cpp | 4 +- engine/src/events/late_war.cpp | 4 +- engine/src/state_machine.cpp | 7 ++ engine/tests/test_bugs_regression.cpp | 101 ++++++++++++++++++++ 18 files changed, 372 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ae3cba7d..dcd2a60c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -336,7 +336,9 @@ non-decreasing country rank, most important first or last, one sequence per boar points releases its floor (`engine/AGENTS.md` §21); a branch adds one with `--resume-add-rules`, and a person plays neither. Two observation bits of no width let a network see its run's order: `PLACEMENT_LEGAL` (bit 7, the mask in the "I can place here" slot, made V1 worse) and `PLACEMENT_ORDER` (bit 8, that slot keeps access and -marks only order-blocked countries 0.5). A seat plays the rule bits of the feature set it is observed in, a +marks only order-blocked countries 0.5). `OWN_EVENT_GATED` (bit 9) and `ALWAYS_ASK_DISCARD` (bit 10, 2026-11-11) are +rule bits a person plays and no network does (no training name): no Event offered that would do nothing, and Blockade +/ Latin American Debt Crisis always ask the US (`engine/AGENTS.md` §22). A seat plays the rule bits of the feature set it is observed in, a search both sides of its tree under its own network's, and a person in the web page every rule bit; an unseated side plays the rule from before every bit, so the unseated engine is E8's. Adding a block still needs the owner. diff --git a/ai/models/ladder_net.py b/ai/models/ladder_net.py index 63f11778..c0276526 100644 --- a/ai/models/ladder_net.py +++ b/ai/models/ladder_net.py @@ -61,7 +61,9 @@ 1 << 5: 0, # CANONICAL_LAST (P43, a rule bit, no width) 1 << 6: 0, # CANONICAL_LEARNED (P43 V3, a rule bit, no width) 1 << 7: 0, # PLACEMENT_LEGAL (edits base slot 19, no width) - 1 << 8: 0} # PLACEMENT_ORDER (edits base slot 19, no width) + 1 << 8: 0, # PLACEMENT_ORDER (edits base slot 19, no width) + 1 << 9: 0, # OWN_EVENT_GATED (a person's rule bit, no width) + 1 << 10: 0} # ALWAYS_ASK_DISCARD (a person's rule bit, no width) def _obs_extra(features: int) -> int: diff --git a/bindings/AGENTS.md b/bindings/AGENTS.md index 5f387a4f..0c2ee695 100644 --- a/bindings/AGENTS.md +++ b/bindings/AGENTS.md @@ -80,7 +80,18 @@ are cited from elsewhere; keep them. `ts_env.seat_tree`; `seat_decider` seats the real position's decider. `(seed, actions)` datasets record `us_obs_features` / `ussr_obs_features`, which `WarmupDataset` seats (absent: 0). The wasm build exports `ts_seat_obs_features`, `ts_seat_rules` and `ts_obs_features_rules`, the rules a - person plays: `OBS_FEATURES_PERSON_RULES`, every rule bit but the canonical orders. + person plays: `OBS_FEATURES_PERSON_RULES`, every rule bit but the canonical orders + (`OBS_FEATURE_OPP_EVENT_GATED`, and `OBS_FEATURE_OWN_EVENT_GATED` / `OBS_FEATURE_ALWAYS_ASK_DISCARD`, + a person's rules with no training name, [`engine/AGENTS.md`](../engine/AGENTS.md) §22). + - **For pages that mirror a game played elsewhere** (Twilight Struggle /ai, the analysis board): the wasm + build also exports `ts_step_dice` (a click with both dice of an opposed roll forced), `ts_first_headline_json` + (a copy of the game stopped between the two headlines), `ts_card_location` / `ts_set_card_location` and + `ts_set_rng` (put a real deal or draw on the board), and `ts_rollout_leaf_features` / + `ts_rollout_leaf_feature_count` (Europe at each rollout leaf). `ts_step` accepts UN Intervention played on the + card Grain Sales drew, the one action `StateMachine::step` takes for another decision type. The display + state says, per card in hand, whether its Event would happen now (`event_ready`), who owes Missile Envy's Ops + (`MISSILE_ENVY_US` / `_USSR` in `flags`), Chernobyl's region (`chernobyl_region`), whether "done" is legal + (`legal_actions.can_decline`) and, at Grain Sales' card, whether UN Intervention is (`legal_actions.un_intervention`). - **`BatchedSearch`** ([`batched_search.hpp`](batched_search.hpp)): the C++ tree behind `ai/search/batched_mcts.py` (`backend="cpp"`), Python-only. Up to `capacity` PUCT searches in lockstep, split into `num_groups` evaluation groups: `reset(roots, simulations, rng_state, diff --git a/bindings/CMakeLists.txt b/bindings/CMakeLists.txt index 125d61b2..eb27bd08 100644 --- a/bindings/CMakeLists.txt +++ b/bindings/CMakeLists.txt @@ -44,6 +44,8 @@ if(EMSCRIPTEN) _ts_belief_temperature_weights _ts_belief_worlds _ts_rollout_add_belief _ts_rollout_race _ts_rollout_in_race _ts_obs_size_for _ts_observation_features _ts_click_event_tracks_json _ts_seat_obs_features _ts_seat_rules _ts_obs_features_rules + _ts_step_dice _ts_first_headline_json _ts_card_location _ts_set_card_location _ts_set_rng + _ts_rollout_leaf_features _ts_rollout_leaf_feature_count _malloc _free) list(JOIN TS_WASM_EXPORTS "," TS_WASM_EXPORTS_CSV) target_link_options(ts_engine_wasm PRIVATE diff --git a/bindings/rollout_batch.hpp b/bindings/rollout_batch.hpp index 0098336e..3b6ca565 100644 --- a/bindings/rollout_batch.hpp +++ b/bindings/rollout_batch.hpp @@ -187,6 +187,7 @@ class RolloutBatch { bool all_done() const noexcept { return live() == 0; } size_t listed() const noexcept { return live_order_.size(); } // rows the last fill_live wrote size_t leaves_listed() const noexcept { return leaf_order_.size(); } // rows the last fill_leaves wrote + uint32_t leaf_row(size_t j) const noexcept { return leaf_order_[j]; } // the row of the j-th leaf listed // Rows at their horizon still waiting for a leaf value: what the next fill_leaves writes. size_t leaves_pending() const noexcept { size_t n = 0; diff --git a/bindings/state_json.cpp b/bindings/state_json.cpp index 2d799563..e45f424d 100644 --- a/bindings/state_json.cpp +++ b/bindings/state_json.cpp @@ -7,7 +7,9 @@ #include #include +#include "ts/action_mask.hpp" #include "ts/card_data.hpp" +#include "ts/card_handlers.hpp" #include "ts/engine.hpp" #include "ts/map_data.hpp" @@ -583,9 +585,20 @@ Value display_state(const GameState& state) { CHECK_FLAG(CMC_SUICIDE_LOSS); CHECK_FLAG(EUROPE_CONTROL_WIN); #undef CHECK_FLAG + // Not an effect bit: who received Missile Envy and still holds it, owing its Operations. + if (state.forced_card_id == card_ids::MISSILE_ENVY && state.forced_card_player != Player::NONE && + in_hand_of(state.card_locations[card_ids::MISSILE_ENVY], state.forced_card_player)) { + flags.push(state.forced_card_player == Player::US ? "MISSILE_ENVY_US" : "MISSILE_ENVY_USSR"); + } d.set("flags", std::move(flags)); // uint64: exact in Python; a JS reader must use `flags`, not this, past 2^53. d.set("persistent_effects", Value(static_cast(state.persistent_effects))); + // Chernobyl's region (engine `Region` order), while it is in effect: its bits are past what a + // JS number keeps exactly once higher flags are set. + if (state.has_flag(effect_bits::CHERNOBYL_ACTIVE)) { + d.set("chernobyl_region", I(static_cast((state.persistent_effects & effect_bits::CHERNOBYL_REGION_MASK) + >> effect_bits::CHERNOBYL_REGION_SHIFT))); + } // 6. Countries, by name Value countries = Value::object(); @@ -625,6 +638,15 @@ Value display_state(const GameState& state) { ci.set("id", I(i)); ci.set("name", std::string(CardData::get_card_name(i))); ci.set("ops", I(CardData::get_card(i).ops)); + // Whether its Event would happen were it played now: for its holder, or for the + // opponent whose card it is (an opponent's card fires for them when played). + { + const Player holder = us ? Player::US : Player::USSR; + const Player side = CardData::is_opponent_card(i, holder) ? get_opponent(holder) : holder; + ci.set("event_ready", i != card_ids::THE_CHINA_CARD && + CardHandlers::can_trigger_event(state, i, side) && + CardHandlers::event_has_effect(state, i, side)); + } (us ? us_hand : ussr_hand).push(I(i)); (us ? us_cards : ussr_cards).push(std::move(ci)); break; @@ -705,6 +727,19 @@ Value display_state(const GameState& state) { legal_actions.set("valid_ids", std::move(legal)); legal_actions.set("valid_action_labels", std::move(labels)); legal_actions.set("allow_early_stop", Value(ctx.allow_early_stop != 0)); + // Whether the engine takes "done" here (the flat mask's CONFIRM_DONE): an early stop, a + // choice with nothing left to choose, or an optional bonus the player may decline (Junta's + // and Tear Down This Wall's free coup or realignment) -- which `allow_early_stop` misses. + { + uint8_t flat[FLAT_ACTION_SPACE_SIZE]; + ActionMask::generate_flat_mask_212(state, flat); + legal_actions.set("can_decline", Value(flat[flat_slots::CONFIRM_DONE] != 0)); + // UN Intervention played on the card Grain Sales drew: a card slot at a resolution node, + // which `valid_ids` (the play modes) cannot say. + if (ctx.decision_type == DecisionType::SELECT_PLAY_MODE && ctx.resolving_card == card_ids::GRAIN_SALES) { + legal_actions.set("un_intervention", Value(flat[card_ids::UN_INTERVENTION - 1] != 0)); + } + } d.set("legal_actions", std::move(legal_actions)); const bool terminal = Engine::is_terminal(state); diff --git a/bindings/ts_bindings.cpp b/bindings/ts_bindings.cpp index 43883737..e8851e87 100644 --- a/bindings/ts_bindings.cpp +++ b/bindings/ts_bindings.cpp @@ -1506,6 +1506,8 @@ NB_MODULE(ts_engine, m) { m.attr("OBS_FEATURE_CANONICAL_LEARNED") = static_cast(ts::obs_features::CANONICAL_LEARNED); m.attr("OBS_FEATURE_PLACEMENT_LEGAL") = static_cast(ts::obs_features::PLACEMENT_LEGAL); m.attr("OBS_FEATURE_PLACEMENT_ORDER") = static_cast(ts::obs_features::PLACEMENT_ORDER); + m.attr("OBS_FEATURE_OWN_EVENT_GATED") = static_cast(ts::obs_features::OWN_EVENT_GATED); + m.attr("OBS_FEATURE_ALWAYS_ASK_DISCARD") = static_cast(ts::obs_features::ALWAYS_ASK_DISCARD); m.attr("OBS_FEATURES_RULES") = static_cast(ts::obs_features::RULES); m.attr("OBS_FEATURES_PERSON_RULES") = static_cast(ts::obs_features::PERSON_RULES); m.attr("OBS_FEATURES_ALL") = static_cast(ts::obs_features::ALL); diff --git a/bindings/ts_engine/__init__.pyi b/bindings/ts_engine/__init__.pyi index 10fcb950..67566ea2 100644 --- a/bindings/ts_engine/__init__.pyi +++ b/bindings/ts_engine/__init__.pyi @@ -1183,11 +1183,15 @@ OBS_FEATURE_PLACEMENT_LEGAL: int = 128 OBS_FEATURE_PLACEMENT_ORDER: int = 256 -OBS_FEATURES_RULES: int = 120 +OBS_FEATURE_OWN_EVENT_GATED: int = 512 -OBS_FEATURES_PERSON_RULES: int = 8 +OBS_FEATURE_ALWAYS_ASK_DISCARD: int = 1024 -OBS_FEATURES_ALL: int = 510 +OBS_FEATURES_RULES: int = 1656 + +OBS_FEATURES_PERSON_RULES: int = 1544 + +OBS_FEATURES_ALL: int = 2046 OBS_FLAG_STAGED_CARDS: int = 1 diff --git a/bindings/wasm/ts_engine_wasm.cpp b/bindings/wasm/ts_engine_wasm.cpp index c0eeb995..8f6b8f93 100644 --- a/bindings/wasm/ts_engine_wasm.cpp +++ b/bindings/wasm/ts_engine_wasm.cpp @@ -96,11 +96,13 @@ void keep_event_tracks(const ts::GameState& s) { } // `settled`, if given, takes the state after each step that rolled a die (see g_settled), and that -// step's roll joins g_rolls. -bool drain(ts::GameState& s, int forced_die, ts::GameState* settled = nullptr) { +// step's roll joins g_rolls. `forced_opp_die` is the other die of an opposed roll (a realignment: +// the roller's die is `forced_die`, the opponent's this one), 0 to roll it. +bool drain(ts::GameState& s, int forced_die, ts::GameState* settled = nullptr, int forced_opp_die = 0) { while (!ts::Engine::is_terminal(s) && s.ctx().decision_player == ts::Player::NONE && s.ctx().decision_type == ts::DecisionType::ROLL_DIE) { - const ts::MicroAction roll{ts::DecisionType::ROLL_DIE, static_cast(forced_die), 0, 0}; + const ts::MicroAction roll{ts::DecisionType::ROLL_DIE, static_cast(forced_die), + static_cast(forced_opp_die), 0}; if (!ts::Engine::step(s, roll)) return false; if (settled && s.last_roll.type != ts::RollType::NONE) { *settled = s; @@ -112,9 +114,10 @@ bool drain(ts::GameState& s, int forced_die, ts::GameState* settled = nullptr) { } // All or nothing: a refused action, or a refused forced die, leaves the game where it was. -int apply(const ts::MicroAction& action, int forced_die) { +int apply(const ts::MicroAction& action, int forced_die, int forced_opp_die = 0) { if (ts::Engine::is_terminal(g_state)) { g_error = "the game is over"; return 0; } if (forced_die < 0 || forced_die > 6) { g_error = "forced die must be 0..6"; return 0; } + if (forced_opp_die < 0 || forced_opp_die > 6) { g_error = "forced die must be 0..6"; return 0; } const ts::GameState before = g_state; if (!ts::Engine::step(g_state, action)) { g_state = before; @@ -126,7 +129,7 @@ int apply(const ts::MicroAction& action, int forced_die) { g_event_tracks.clear(); keep_roll(g_state); keep_event_tracks(g_state); - if (!drain(g_state, forced_die, &g_settled)) { + if (!drain(g_state, forced_die, &g_settled, forced_opp_die)) { g_state = before; g_error = "the engine refused the die roll"; return 0; @@ -547,17 +550,78 @@ TS_API int ts_decision_type() { return static_cast(g_state.ctx().decision_t // ---- acting ----------------------------------------------------------------------------------- +// Is `a` an answer to the open decision? Its type is the decision's, but for one action the engine +// itself excepts (StateMachine::step): UN Intervention played ON the card Grain Sales drew arrives +// card-shaped at a resolution node. Refusing it here made a move the mask offers impossible -- for +// the AI, a refused move and a game that stops. The engine's mask still has the final say. +static bool for_open_decision(const ts::MicroAction& a) { + const auto& c = g_state.ctx(); + return a.decision_type == c.decision_type || + (c.decision_type == ts::DecisionType::SELECT_PLAY_MODE && c.resolving_card == ts::card_ids::GRAIN_SALES && + a.decision_type == ts::DecisionType::SELECT_CARD && a.primary_id == ts::card_ids::UN_INTERVENTION); +} + // A click: one MicroAction, then the chance nodes it lands on (forced_die 0 = roll). TS_API int ts_step(int decision_type, int primary, int secondary, int flags, int forced_die) { const ts::MicroAction a{static_cast(decision_type), static_cast(primary), static_cast(secondary), static_cast(flags)}; - if (a.decision_type != g_state.ctx().decision_type) { + if (!for_open_decision(a)) { g_error = "the action is for a different decision than the one open"; return 0; } return apply(a, forced_die); } +// The same, with both dice of an opposed roll named: `forced_opp_die` is the opponent's die of a +// realignment (0 = roll it). Twilight Struggle /ai uses it to enter the dice of a game played +// elsewhere, where both players rolled real dice. +TS_API int ts_step_dice(int decision_type, int primary, int secondary, int flags, int forced_die, + int forced_opp_die) { + const ts::MicroAction a{static_cast(decision_type), static_cast(primary), + static_cast(secondary), static_cast(flags)}; + if (!for_open_decision(a)) { + g_error = "the action is for a different decision than the one open"; + return 0; + } + return apply(a, forced_die, forced_opp_die); +} + +// The first headline on its own, for the page's log. Steps a copy of the game with the action (and +// its dice, as ts_step_dice), stopping where the first headline is done and the second not begun +// (StateMachine::pause_between_headlines), and returns {"state": , "rolls": n} -- n the +// dice rolled up to there -- or "" when the action does not get there: no headline resolving, or +// the game over with the first. The game itself is not touched. The real step's own dice record +// is cleared by the next ts_step, which is why this may run just before it. +TS_API const char* ts_first_headline_json(int decision_type, int primary, int secondary, int flags, int forced_die, + int forced_opp_die) { + const ts::MicroAction a{static_cast(decision_type), static_cast(primary), + static_cast(secondary), static_cast(flags)}; + if (g_state.current_phase != ts::Phase::HEADLINE) return keep(""); + ts::GameState copy = g_state; + g_rolls.clear(); + ts::StateMachine::pause_between_headlines = true; + ts::StateMachine::paused_between_headlines = false; + // As apply() and drain() step a click, but no further than the pause: a die node the first + // headline left behind is not one to roll. + bool ok = ts::Engine::step(copy, a); + if (ok) keep_roll(copy); + while (ok && !ts::StateMachine::paused_between_headlines && !ts::Engine::is_terminal(copy) && + copy.ctx().decision_player == ts::Player::NONE && copy.ctx().decision_type == ts::DecisionType::ROLL_DIE) { + const ts::MicroAction roll{ts::DecisionType::ROLL_DIE, static_cast(forced_die), + static_cast(forced_opp_die), 0}; + ok = ts::Engine::step(copy, roll); + if (ok) keep_roll(copy); + } + const bool paused = ts::StateMachine::paused_between_headlines; + ts::StateMachine::pause_between_headlines = false; + ts::StateMachine::paused_between_headlines = false; + if (!ok || !paused) return keep(""); + sj::Value out = sj::Value::object(); + out.set("state", sj::display_state(copy)); + out.set("rolls", static_cast(g_rolls.size())); + return keep(out.dump()); +} + // The flat legal mask in an action view: 0 E4, 1 E4.1's merged influence, 2 P42's macro (until-control) view. TS_API const uint8_t* ts_mask(int view) { if (view < 0 || view > 2) view = 0; @@ -662,6 +726,29 @@ TS_API int ts_set_vp(int v) { return 1; } +// ---- entering a game played elsewhere (Twilight Struggle /ai) ---------------------------------- +// +// The analysis board mirrors a game played at a table or in another app, so what chance decided +// there has to be put on the board here: which cards were dealt, which card a random draw took. +// A deal is not a decision, and the engine draws from its RNG; these two calls are how the page +// corrects it. Moving a card keeps every count right only if the page swaps cards in pairs, which +// it does; the engine itself checks nothing here, as with the debug overrides above. + +TS_API int ts_card_location(int card) { + if (card < 1 || card > 110) { g_error = "card ids are 1..110"; return -1; } + return static_cast(g_state.card_locations[card]); +} +TS_API int ts_set_card_location(int card, int loc) { + if (card < 1 || card > 110) { g_error = "card ids are 1..110"; return 0; } + if (loc < 0 || loc > static_cast(ts::CardLocation::HEADLINE_COMMITTED)) { g_error = "no such card location"; return 0; } + g_state.card_locations[card] = static_cast(loc); + return 1; +} +// The RNG state, so a random event can be re-run until it draws the card that was really drawn. +TS_API void ts_set_rng(uint32_t lo, uint32_t hi) { + g_state.rng_state = (static_cast(hi) << 32) | lo; +} + // ---- the rollout root's playouts (bindings/rollout_batch.hpp, the same C++ as Python's) -------- // // One batch at a time. The page: ts_rollout_reset, ts_rollout_add on the position it holds (its @@ -912,6 +999,38 @@ TS_API int ts_rollout_fill_leaves() { g_ro_masks.assign(std::max(1, n * ts::FLAT_ACTION_SPACE_SIZE), 0); return static_cast(g_rollout->fill_leaves(g_ro_obs.data(), g_ro_masks.data())); } +// What the last ts_rollout_fill_leaves listed, read from each leaf's root mover's side, for a page +// that weighs the critic's value with what it knows the critic misjudges -- Europe, above all, where +// a player who controls it when the scoring card comes wins at once. kLeafFeatures floats a leaf, in +// the order fill_leaves wrote them: the mover's and the opponent's Europe status (0 none, 1 presence, +// 2 domination, 3 control), Europe battlegrounds the mover controls less the opponent's, where +// Europe Scoring is in that world (0 the mover's hand, 1 the draw deck, 2 the opponent's hand, 3 +// anywhere else: played, discarded), and the turn. +namespace { +constexpr int kLeafFeatures = 5; +std::vector g_ro_leaf_features; +} // namespace +TS_API int ts_rollout_leaf_feature_count() { return kLeafFeatures; } +TS_API const float* ts_rollout_leaf_features() { + const size_t n = g_rollout ? g_rollout->leaves_listed() : 0; + g_ro_leaf_features.assign(std::max(1, n * kLeafFeatures), 0.0f); + for (size_t j = 0; j < n; ++j) { + const auto& row = g_rollout->row(g_rollout->leaf_row(j)); + const ts::Player me = g_rollout->mover(row.position); + const auto e = ts::Scoring::evaluate_region(row.state, ts::Region::EUROPE); + const bool us = me == ts::Player::US; + float* f = g_ro_leaf_features.data() + j * kLeafFeatures; + f[0] = static_cast(us ? e.us_status : e.ussr_status); + f[1] = static_cast(us ? e.ussr_status : e.us_status); + f[2] = static_cast(us ? e.us_battlegrounds - e.ussr_battlegrounds : e.ussr_battlegrounds - e.us_battlegrounds); + const auto loc = row.state.card_locations[ts::card_ids::EUROPE_SCORING]; + f[3] = ts::in_hand_of(loc, me) ? 0.0f : loc == ts::CardLocation::DRAW_DECK ? 1.0f + : ts::in_hand_of(loc, ts::get_opponent(me)) ? 2.0f : 3.0f; + f[4] = static_cast(row.state.turn); + } + return g_ro_leaf_features.data(); +} + TS_API int ts_rollout_set_leaf_values(const float* values) { if (!g_rollout) { g_error = "ts_rollout_reset first"; return 0; } if (any_nan(values, g_rollout->leaves_listed())) { g_error = "a leaf value is NaN"; return 0; } diff --git a/engine/AGENTS.md b/engine/AGENTS.md index 9d7fed79..e351481e 100644 --- a/engine/AGENTS.md +++ b/engine/AGENTS.md @@ -629,9 +629,34 @@ has one action sequence. The helpers are `ts/canonical.hpp`. 400M. `PLACEMENT_ORDER` (bit 8, 2026-11-08) keeps slot 19 as access and marks only the countries the run's order blocks now as 0.5 (0 no access, 1 access), while a floor is in force and not released. Both edit a base slot at no width, so only a model trained with the bit reads them so. -* **A person plays none of them** (`obs_features::PERSON_RULES` for the page's current rules). +* **A person plays none of them** (`obs_features::PERSON_RULES` for the page's current rules: ENG-9's bit and §22's). Tests: `CanonicalPlacement.*` in `engine/tests/test_canonical_placement.cpp` -- every setup board reached by exactly one sequence, exhaustively, for both orders; and on Ops plays and covered event runs from 120 random games, the boards a canonical seat reaches equal free order's, exhaustively per run, for both orders. `tests/bindings/test_canonical_placement.py`. + +## 22. A person's rules: events that would do nothing, and discards that tell (owner 2026-11-11) + +Two **rule bits** of no width, in `obs_features::RULES` and `PERSON_RULES` (a person plays them; no +training name, so no network does). Unseated, both are the rule from before them, and the behaviour +digest is unchanged (E8's, as on `main`). + +* **`OWN_EVENT_GATED`** (bit 9): a seat's own or neutral card offers its Event only where + `CardHandlers::event_has_effect` says something would happen. That already covered Our Man in Tehran and + Kitchen Debates; for a seat playing the bit it also covers **One Small Step** (only behind in space) and + **Wargames** (only at DEFCON 2), whose Events are nothing but their "If ...". Played anyway (a headline), + such a card did not occur, so a starred one is discarded rather than removed (`relocate_played_card`). + Without the bit both cases return true, as before: offered, nothing happens, removed. +* **`ALWAYS_ASK_DISCARD`** (bit 10): Blockade and Latin American Debt Crisis ask the US even when it holds no + card of 3+ effective Ops; its one legal answer is then CONFIRM_DONE. Resolving at once told the USSR the + US hand held no such card, which at a table nobody learns. Read on **either** seat + (`either_seat_plays_rule`): it guards the USSR's ignorance as much as it asks the US, so a person on either + side of a game against a network gets it. With auto-advance the game is the same as without the bit (the + forced "done" is taken), one micro-step longer. + +The page also has a display-only hook here: `StateMachine::pause_between_headlines` (thread-local, off +except inside the wasm `ts_first_headline_json`) stops a copy of the game between the two headlines, so the +first headline's VP can be shown alone. + +Tests: `OwnEventGated*`, `AlwaysAskDiscard*` in `engine/tests/test_bugs_regression.cpp`. diff --git a/engine/include/ts/game_state.hpp b/engine/include/ts/game_state.hpp index 864fac91..e3d30e48 100644 --- a/engine/include/ts/game_state.hpp +++ b/engine/include/ts/game_state.hpp @@ -622,14 +622,25 @@ namespace obs_features { // 0.5 access but blocked by the order). PLACEMENT_LEGAL's mask-in-the-slot changed the slot at 71% of placement // decisions and made V1 worse; this changes it only where the order blocks. constexpr uint32_t PLACEMENT_ORDER = 1u << 8; + // A rule bit (no width, owner 2026-11-11): a seat's own or neutral card offers its Event only where + // it would do something (CardHandlers::event_has_effect), and the Events that are nothing but an + // unmet "If ..." -- One Small Step not behind in space, Wargames off DEFCON 2, with Our Man in + // Tehran and Kitchen Debates -- count as not occurring, so a starred one played that way (a + // headline) is discarded, not removed. Clear, they are offered and removed as before. + constexpr uint32_t OWN_EVENT_GATED = 1u << 9; + // A rule bit (no width, owner 2026-11-11): Blockade and Latin American Debt Crisis ask the US even + // when it holds no card of 3+ Ops (its one answer is then "done"), so the USSR never learns that it + // had none. Read on either seat: it guards the USSR's ignorance and asks the US. With auto-advance + // the game is the same as without it -- one forced decision more; clear, the event resolves at once. + constexpr uint32_t ALWAYS_ASK_DISCARD = 1u << 10; constexpr float BOARD_BLOCKED_BY_ORDER = 0.5f; constexpr size_t BOARD_MY_PLACEABLE = 19; // The bits that change the rules a seat plays (GameState::seat_rules), not its observation. - constexpr uint32_t RULES = OPP_EVENT_GATED | CANONICAL; + constexpr uint32_t RULES = OPP_EVENT_GATED | CANONICAL | OWN_EVENT_GATED | ALWAYS_ASK_DISCARD; // The rule bits a person plays (the page's current rules): game rules, not a model's action order. - constexpr uint32_t PERSON_RULES = OPP_EVENT_GATED; + constexpr uint32_t PERSON_RULES = OPP_EVENT_GATED | OWN_EVENT_GATED | ALWAYS_ASK_DISCARD; constexpr uint32_t ALL = OPS_BUDGET | HEADLINE_REVEAL | OPP_EVENT_GATED | CANONICAL | PLACEMENT_LEGAL | - PLACEMENT_ORDER; + PLACEMENT_ORDER | OWN_EVENT_GATED | ALWAYS_ASK_DISCARD; constexpr size_t OPS_BUDGET_WIDTH = 3; @@ -655,6 +666,11 @@ inline bool seat_plays_rule(const GameState& state, Player p, uint32_t rule) noe return (state.seat_rules[seat_index(p)] & rule) != 0; } +// A rule that guards what one side may learn of the other's hand is the game's once either seat plays it. +inline bool either_seat_plays_rule(const GameState& state, uint32_t rule) noexcept { + return ((state.seat_rules[0] | state.seat_rules[1]) & rule) != 0; +} + namespace ctx_slots { // 72, not 76: legacy reserved 76 globals and only ever wrote 0..71, so v2.2 reclaims // the four it left blank rather than carrying them forward as padding. diff --git a/engine/include/ts/state_machine.hpp b/engine/include/ts/state_machine.hpp index cdd3b9b4..67f79d9a 100644 --- a/engine/include/ts/state_machine.hpp +++ b/engine/include/ts/state_machine.hpp @@ -20,6 +20,14 @@ class StateMachine { // Phase Transitions static void start_turn(GameState& state) noexcept; static void advance_headline_step(GameState& state) noexcept; + // For display only, never in play: while `pause_between_headlines` is set, the headline phase + // stops where the first headline is done and the second not begun, and `paused_between_headlines` + // says it did. A copy of the game stepped this way shows what the first headline did alone -- + // its VP, which can end the game on their own (20 VP) before the second resolves. Off, the + // engine is unchanged. Only the browser build sets it (ts_first_headline_json); per thread, so a + // native harness stepping games on many threads never sees another's. + static thread_local bool pause_between_headlines; + static thread_local bool paused_between_headlines; static void advance_after_ops(GameState& state) noexcept; static void advance_after_action_round(GameState& state) noexcept; static void finish_play_without_ops(GameState& state) noexcept; diff --git a/engine/src/action_mask.cpp b/engine/src/action_mask.cpp index 6c6e17f7..996672e8 100644 --- a/engine/src/action_mask.cpp +++ b/engine/src/action_mask.cpp @@ -316,7 +316,11 @@ void ActionMask::generate_mask(const GameState& state, uint8_t* mask_out, size_t CardHandlers::can_trigger_event(state, card, get_opponent(p))) { mask_out[static_cast(Resolution::EVENT)] = 1; } - } else if (CardHandlers::can_trigger_event(state, card, p)) { + } else if (CardHandlers::can_trigger_event(state, card, p) && + (!seat_plays_rule(state, p, obs_features::OWN_EVENT_GATED) || + CardHandlers::event_has_effect(state, card, p))) { + // A seat playing OWN_EVENT_GATED is not offered its own or a neutral card's Event + // where nothing would happen: an unmet "If ..." (event_has_effect). mask_out[static_cast(Resolution::EVENT)] = 1; } diff --git a/engine/src/card_dispatcher.cpp b/engine/src/card_dispatcher.cpp index 8a302897..6e2ab6e5 100644 --- a/engine/src/card_dispatcher.cpp +++ b/engine/src/card_dispatcher.cpp @@ -278,6 +278,16 @@ bool CardHandlers::event_has_effect(const GameState& state, uint8_t card_id, Pla switch (card_id) { case card_ids::OUR_MAN_IN_TEHRAN: return us_controls_a_middle_east_country(state); case card_ids::KITCHEN_DEBATES: return us_leads_on_battlegrounds(state); + // Two more Events that are nothing but their "If ...", for a seat that plays OWN_EVENT_GATED: + // unmet, nothing happens, the Event is not offered (action_mask.cpp) and a starred card played + // so anyway (a headline) is discarded, not removed. Without the bit they occur, as before. + case card_ids::ONE_SMALL_STEP: + if (!seat_plays_rule(state, player, obs_features::OWN_EVENT_GATED)) return true; + return player == Player::US ? state.us_space_track < state.ussr_space_track + : state.ussr_space_track < state.us_space_track; + case card_ids::WARGAMES: + if (!seat_plays_rule(state, player, obs_features::OWN_EVENT_GATED)) return true; + return state.defcon == 2; default: return true; } } diff --git a/engine/src/events/early_war.cpp b/engine/src/events/early_war.cpp index 74277ff6..71d62aa9 100644 --- a/engine/src/events/early_war.cpp +++ b/engine/src/events/early_war.cpp @@ -119,7 +119,9 @@ bool trigger_blockade(GameState& state, Player p) noexcept { } } } - if (!has_3ops) { + // Under ALWAYS_ASK_DISCARD the US is asked even with no such card (its one answer is then + // "done"): resolving at once told the USSR the US hand held none, which at a table nobody learns. + if (!has_3ops && !either_seat_plays_rule(state, obs_features::ALWAYS_ASK_DISCARD)) { // Remove all US influence from West Germany state.countries[countries::WEST_GERMANY].us_influence = 0; return true; diff --git a/engine/src/events/late_war.cpp b/engine/src/events/late_war.cpp index dcfd4b53..28b49f2e 100644 --- a/engine/src/events/late_war.cpp +++ b/engine/src/events/late_war.cpp @@ -203,7 +203,9 @@ bool trigger_latin_debt_crisis(GameState& state, Player p) noexcept { break; } } - if (!has_3ops) { + // Under ALWAYS_ASK_DISCARD the US is asked even with no such card, as for Blockade: going + // straight to the doubling told the USSR that it had none. + if (!has_3ops && !either_seat_plays_rule(state, obs_features::ALWAYS_ASK_DISCARD)) { // USSR doubles USSR influence in 2 countries in South America state.ctx().decision_player = Player::USSR; state.ctx().decision_type = DecisionType::POINT_NODE; diff --git a/engine/src/state_machine.cpp b/engine/src/state_machine.cpp index b97eae88..600693bb 100644 --- a/engine/src/state_machine.cpp +++ b/engine/src/state_machine.cpp @@ -243,9 +243,16 @@ void StateMachine::start_turn(GameState& state) noexcept { state.ctx().decision_type = DecisionType::SELECT_CARD; } +thread_local bool StateMachine::pause_between_headlines = false; +thread_local bool StateMachine::paused_between_headlines = false; + void StateMachine::advance_headline_step(GameState& state) noexcept { // Check if we need to resolve headline 2 or enter AR 1 if (state.headline_stage == 1) { + if (pause_between_headlines && state.current_phase != Phase::GAME_OVER) { + paused_between_headlines = true; + return; + } // Move to Stage 2: Second headline state.headline_stage = 2; uint8_t h2_card = state.headline_second_card; diff --git a/engine/tests/test_bugs_regression.cpp b/engine/tests/test_bugs_regression.cpp index 4feef163..f0523339 100644 --- a/engine/tests/test_bugs_regression.cpp +++ b/engine/tests/test_bugs_regression.cpp @@ -970,6 +970,107 @@ TEST(RegressionTest, UnseatedStatePlaysThePreFixRule) { ASSERT_FALSE(seat_plays_rule(state, Player::US, obs_features::OPP_EVENT_GATED)); } +// OWN_EVENT_GATED (a rule bit, owner 2026-11-11): a seat's own or neutral card whose Event is nothing +// but an unmet "If ..." is not offered as an Event. Unseated, the Event is offered and does nothing. +namespace { +bool event_offered(GameState& state, Player p, uint8_t card) { + state.card_locations[card] = ts::hand_of(p); + state.current_phase = Phase::ACTION_ROUND; + state.action_round = 1; + state.phasing_player = p; + state.ctx().decision_player = p; + state.ctx().decision_type = DecisionType::SELECT_PLAY_MODE; + state.ctx().pending_op_card = card; + uint8_t mask[128]{}; + size_t out_size = 0; + ActionMask::generate_mask(state, mask, &out_size); + return mask[static_cast(Resolution::EVENT)] != 0; +} +} // namespace + +TEST(RegressionTest, OwnEventGatedHidesOneSmallStepUnlessBehind) { + GameState state{}; + Engine::init_game(state, 42); + state.us_space_track = 2; + state.ussr_space_track = 1; + ASSERT_TRUE(event_offered(state, Player::US, card_ids::ONE_SMALL_STEP)); // unseated: as before + ASSERT_TRUE(CardHandlers::event_has_effect(state, card_ids::ONE_SMALL_STEP, Player::US)); + + seat_obs_features(state, Player::US, obs_features::OWN_EVENT_GATED); + ASSERT_FALSE(event_offered(state, Player::US, card_ids::ONE_SMALL_STEP)); // ahead + ASSERT_FALSE(CardHandlers::event_has_effect(state, card_ids::ONE_SMALL_STEP, Player::US)); + state.ussr_space_track = 2; + ASSERT_FALSE(event_offered(state, Player::US, card_ids::ONE_SMALL_STEP)); // level + state.ussr_space_track = 3; + ASSERT_TRUE(event_offered(state, Player::US, card_ids::ONE_SMALL_STEP)); // behind + ASSERT_TRUE(CardHandlers::event_has_effect(state, card_ids::ONE_SMALL_STEP, Player::US)); +} + +TEST(RegressionTest, OwnEventGatedHidesWargamesOffDefcon2) { + GameState state{}; + Engine::init_game(state, 42); + state.defcon = 3; + seat_obs_features(state, Player::USSR, obs_features::OWN_EVENT_GATED); + ASSERT_FALSE(event_offered(state, Player::USSR, card_ids::WARGAMES)); + ASSERT_TRUE(event_offered(state, Player::US, card_ids::WARGAMES)); // the US seat is unseated + state.defcon = 2; + ASSERT_TRUE(event_offered(state, Player::USSR, card_ids::WARGAMES)); +} + +TEST(RegressionTest, OwnEventGatedHidesOurManInTehranWithoutAMiddleEastCountry) { + GameState state{}; + Engine::init_game(state, 42); + for (uint8_t i = 0; i < 84; ++i) { + if (MapData::get_country(i).region == Region::MIDDLE_EAST) state.countries[i].us_influence = 0; + } + ASSERT_TRUE(event_offered(state, Player::US, card_ids::OUR_MAN_IN_TEHRAN)); + seat_obs_features(state, Player::US, obs_features::OWN_EVENT_GATED); + ASSERT_FALSE(event_offered(state, Player::US, card_ids::OUR_MAN_IN_TEHRAN)); +} + +// ALWAYS_ASK_DISCARD (a rule bit, owner 2026-11-11): Blockade asks the US even with no card of 3+ Ops, +// so the USSR does not learn it had none. Either seat's bit asks; the only answer is "done", the one +// legal action auto-advance takes, and West Germany is cleared as it was at once without the bit. +TEST(RegressionTest, AlwaysAskDiscardAsksBlockadeWithNothingToDiscard) { + for (const Player seat : {Player::US, Player::USSR}) { + GameState state{}; + state.countries[countries::WEST_GERMANY].us_influence = 4; + seat_obs_features(state, seat, obs_features::ALWAYS_ASK_DISCARD); + ASSERT_FALSE(CardHandlers::trigger_event(state, card_ids::BLOCKADE, Player::USSR)); + ASSERT_EQ(state.ctx().decision_player, Player::US); + ASSERT_EQ(state.ctx().decision_type, DecisionType::SELECT_CARD); + ASSERT_EQ(state.countries[countries::WEST_GERMANY].us_influence, 4); + + uint8_t flat[FLAT_ACTION_SPACE_SIZE]{}; + ActionMask::generate_flat_mask_212(state, flat); + int legal = 0; + for (size_t i = 0; i < FLAT_ACTION_SPACE_SIZE; ++i) legal += flat[i]; + ASSERT_EQ(legal, 1); + ASSERT_EQ(flat[flat_slots::CONFIRM_DONE], 1); + + MicroAction done{}; + done.decision_type = DecisionType::SELECT_CARD; + done.flags = action_flags::CONFIRM_DONE; + ASSERT_TRUE(CardHandlers::handle_event_step(state, done)); + ASSERT_EQ(state.countries[countries::WEST_GERMANY].us_influence, 0); + } +} + +TEST(RegressionTest, AlwaysAskDiscardAsksLatinAmericanDebtCrisisWithNothingToDiscard) { + GameState state{}; + seat_obs_features(state, Player::USSR, obs_features::ALWAYS_ASK_DISCARD); + ASSERT_FALSE(CardHandlers::trigger_event(state, card_ids::LATIN_AMERICAN_DEBT_CRISIS, Player::USSR)); + ASSERT_EQ(state.ctx().decision_player, Player::US); + ASSERT_EQ(state.ctx().decision_type, DecisionType::SELECT_CARD); + + MicroAction done{}; + done.decision_type = DecisionType::SELECT_CARD; + done.flags = action_flags::CONFIRM_DONE; + ASSERT_FALSE(CardHandlers::handle_event_step(state, done)); + ASSERT_EQ(state.ctx().decision_player, Player::USSR); // the doubling + ASSERT_EQ(state.ctx().decision_type, DecisionType::POINT_NODE); +} + // ENG-10: a target is offered only if its cost fits the budget that spending there keeps. // The region bonuses were withdrawn only after the Op was spent, so the last one could go // anywhere: the China Card under Vietnam Revolts placed five Ops in Southeast Asia and the