From d1bc5d555ad955c99d8ecad7578044ae7782d723 Mon Sep 17 00:00:00 2001 From: samtcifihi <43021399+samtcifihi@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:04:32 -0400 Subject: [PATCH 1/2] Fix position challenges to use the selected move-tree node --- CHANGELOG.md | 1 + src/lib/challengeStartPosition.js | 4 +- src/lib/challengeStartPosition.test.js | 94 +++++++++++++++++++++++++- 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 422a4f12..1e5d27fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ Since the `1.0.0-beta` release, the version in `package.json` has stayed at `1.0 ### Fixed +- **Position challenges:** “Challenge from position” and “Teach this position” use the selected move-tree position, including exploration branches and earlier main-line moves, for both the preview and challenge payload. - **In-game chat:** unsent composer text is kept when switching drawer or overlay tabs (strip, card, and other layouts that unmount chat). - **Stack-expanding display:** Re-derive `stackExpanding` from game flags and the current render rep on each sync (and when changing display in settings) so switching from 3D to expanding (e.g. Volcano in Lab) enables stack hover again. - **Customize:** per-game settings JSON and saves include the full `render` block (e.g. `hide-labels-half`) when those options are checked — no longer omitted from the editor blob when they match global defaults. diff --git a/src/lib/challengeStartPosition.js b/src/lib/challengeStartPosition.js index 08213146..712be24b 100644 --- a/src/lib/challengeStartPosition.js +++ b/src/lib/challengeStartPosition.js @@ -1,4 +1,4 @@ -import { getMainLineTipState } from "./Lab/exploration"; +import { getFocusNode } from "./Lab/exploration"; import { isTeachingChallengeEligible } from "./teachingModeChallenge"; /** Mirrors backend teaching eligibility for start-position challenges. */ @@ -28,7 +28,7 @@ export function buildStartPositionFromLabSession({ game, nodes, focus }) { if (!game?.metaGame || !nodes?.length || !focus) { throw new Error("Cannot build start position from this session."); } - const state = getMainLineTipState(nodes, game); + const state = getFocusNode(nodes, game, focus)?.state; if (!state) { throw new Error("Start position state is missing."); } diff --git a/src/lib/challengeStartPosition.test.js b/src/lib/challengeStartPosition.test.js index 7e040aff..d8959f3b 100644 --- a/src/lib/challengeStartPosition.test.js +++ b/src/lib/challengeStartPosition.test.js @@ -3,6 +3,7 @@ import { GameFactory } from "@abstractplay/gameslib"; import { buildStartPositionPayload, buildStartPositionFromLabSession, + buildStartPositionFromGameMoveSession, isStartPositionChallengeEligible, } from "./challengeStartPosition"; @@ -19,7 +20,7 @@ describe("challengeStartPosition", () => { expect(isStartPositionChallengeEligible("saltire", 3, [])).toBe(false); }); - it("buildStartPositionFromLabSession uses tip state at focus", () => { + it("buildStartPositionFromLabSession uses the selected main-line tip", () => { const engine = GameFactory("saltire", undefined, []); const opening = engine.serialize(); engine.move(engine.moves()[0]); @@ -39,3 +40,94 @@ describe("challengeStartPosition", () => { expect(draft.playerCount).toBe(2); }); }); + +describe.each([ + ["Lab", buildStartPositionFromLabSession], + ["Game Move", buildStartPositionFromGameMoveSession], +])("%s start-position selection", (_name, buildStartPosition) => { + it("uses an earlier main-line position, hydrating its snapshot", () => { + const engine = GameFactory("saltire", undefined, []); + engine.move(engine.moves()[0]); + const selected = engine.serialize(); + engine.move(engine.moves()[0]); + const tip = engine.serialize(); + const nodes = [ + { state: null, children: [] }, + { state: null, children: [] }, + { state: tip, children: [] }, + ]; + const draft = buildStartPosition({ + game: { metaGame: "saltire", state: tip, selectedVariants: [] }, + nodes, + focus: { moveNumber: 1, exPath: [] }, + }); + const actual = GameFactory("saltire", draft.state); + const expected = GameFactory("saltire", selected); + expect(actual.stack).toHaveLength(2); + expect(actual.board).toEqual(expected.board); + expect(actual.currplayer).toBe(expected.currplayer); + }); + + it("uses the nested Arimaa exploration branch from the selected focus", () => { + const main = GameFactory("arimaa", undefined, ["free"]); + const opening = main.serialize(); + main.move("Re1, Ee2, Md2, Hc2, Hb2, Da2, Da1, Cc1, Cb1"); + const mainGold = main.serialize(); + main.move( + "ra8, rb8, rc8, rd8, re8, rf8, rg8, rh8, rh7, rg7, rf7, re7, rd7, rc7, rb7, ra7, ra6, rb6, rc6, rd6, re6, rf6, rg6, rh6, rh5, rg5, rf5, re5, rd5, rc5, rb5, ra5" + ); + const tip = main.serialize(); + + const branch = GameFactory("arimaa", opening); + branch.move( + "Ee2, Hb2, Dg2, Cd2, Ra2, Ra1, Rb1, Rc1, Rc2, Rd1, Re1, Rf1, Rf2, Rh1, Rg1, Rh2" + ); + const branchGold = branch.serialize(); + branch.move( + "ed7, me7, hg7, hb7, dd8, de8, cf7, cc7, ra7, ra8, rb8, rc8, rf8, rg8, rh8, rh7" + ); + const selected = branch.serialize(); + const siblings = Array.from({ length: 6 }, () => ({ + state: opening, + children: [], + })); + const nodes = [ + { + state: opening, + children: [ + ...siblings, + { + state: branchGold, + children: [{ state: selected, children: [] }], + }, + ], + }, + { state: mainGold, children: [] }, + { state: tip, children: [] }, + ]; + const draft = buildStartPosition({ + game: { metaGame: "arimaa", state: tip, selectedVariants: ["free"] }, + nodes, + focus: { moveNumber: 0, exPath: [6, 0] }, + }); + + expect(draft.state).toBe(selected); + expect(buildStartPositionPayload(draft.state)).toEqual({ + startPosition: { state: selected }, + }); + expect(draft.variants).toEqual(["free"]); + const actual = GameFactory("arimaa", draft.state); + expect(actual.board.get("d7")).toEqual(["E", 2]); + expect(actual.board.size).toBe(32); + }); + + it("rejects a missing focus node instead of using the main-line tip", () => { + expect(() => + buildStartPosition({ + game: { metaGame: "saltire" }, + nodes: [{ state: "tip", children: [] }], + focus: { moveNumber: 0, exPath: [0] }, + }) + ).toThrow("Start position state is missing."); + }); +}); From 38ef460cbc6b8ee42943334376cdd81499aa2e19 Mon Sep 17 00:00:00 2001 From: samtcifihi <43021399+samtcifihi@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:11:12 -0400 Subject: [PATCH 2/2] Remove position challenge changelog entry --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e5d27fa..422a4f12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,7 +45,6 @@ Since the `1.0.0-beta` release, the version in `package.json` has stayed at `1.0 ### Fixed -- **Position challenges:** “Challenge from position” and “Teach this position” use the selected move-tree position, including exploration branches and earlier main-line moves, for both the preview and challenge payload. - **In-game chat:** unsent composer text is kept when switching drawer or overlay tabs (strip, card, and other layouts that unmount chat). - **Stack-expanding display:** Re-derive `stackExpanding` from game flags and the current render rep on each sync (and when changing display in settings) so switching from 3D to expanding (e.g. Volcano in Lab) enables stack hover again. - **Customize:** per-game settings JSON and saves include the full `render` block (e.g. `hide-labels-half`) when those options are checked — no longer omitted from the editor blob when they match global defaults.