diff --git a/packages/scratch-gui/src/components/menu-bar/settings-menu.jsx b/packages/scratch-gui/src/components/menu-bar/settings-menu.jsx index f9179cfedfa..489e2cb3d23 100644 --- a/packages/scratch-gui/src/components/menu-bar/settings-menu.jsx +++ b/packages/scratch-gui/src/components/menu-bar/settings-menu.jsx @@ -83,11 +83,6 @@ const SettingsMenu = ({ const availableThemesLength = useMemo(() => Object.keys(availableThemesMap).length, [availableThemesMap]); const handleChangeRubyVersion = useCallback(rubyVersion => { - if (rubyVersion === '2' && vm.extensionManager && vm.extensionManager.isExtensionLoaded('koshien')) { - // eslint-disable-next-line no-alert - alert(intl.formatMessage(rubyVersionMessages.koshienCannotChangeRubyVersion)); - return; - } // === Smalruby: Start of v1 switch prevention === // Prevent switching to v1 when v2 features (module/class) are in use if (rubyVersion === '1' && vm.runtime) { // eslint-disable-line react/prop-types diff --git a/packages/scratch-gui/src/components/mobile-drawer/mobile-drawer.jsx b/packages/scratch-gui/src/components/mobile-drawer/mobile-drawer.jsx index 0a530d2d25a..f89c0d63d5b 100644 --- a/packages/scratch-gui/src/components/mobile-drawer/mobile-drawer.jsx +++ b/packages/scratch-gui/src/components/mobile-drawer/mobile-drawer.jsx @@ -323,14 +323,6 @@ const MobileDrawerComponent = ({ onClose(); return; } - // v1 へ切替時は v2 機能が使われていないか確認する。 - // koshien 拡張のチェックは settings-menu 側でもやっているので、 - // モバイルでもそちらのフローに合わせて同じ guard を入れる。 - if (version === '2' && vm?.extensionManager?.isExtensionLoaded?.('koshien')) { - // eslint-disable-next-line no-alert - alert(intl.formatMessage(rubyVersionMessages.koshienCannotChangeRubyVersion)); - return; - } if (version === VERSION_1 && hasV2Features(vm)) { // eslint-disable-next-line no-alert alert(intl.formatMessage(rubyVersionMessages.cannotSwitchToV1)); diff --git a/packages/scratch-gui/src/containers/extension-library.jsx b/packages/scratch-gui/src/containers/extension-library.jsx index c589d87d09d..d95f751a72e 100644 --- a/packages/scratch-gui/src/containers/extension-library.jsx +++ b/packages/scratch-gui/src/containers/extension-library.jsx @@ -32,11 +32,6 @@ const messages = defineMessages({ 'Otherwise, if you want to use the new mesh extension, select Cancel.', description: 'Warning message for legacy mesh extension deprecation', id: 'gui.extensionLibrary.meshDeprecationWarning' - }, - koshienOnlyAvailableForRubyV1: { - defaultMessage: 'The Koshien extension is only available for Ruby v1', - description: 'Error message when trying to use Koshien extension with non-Ruby v1', - id: 'gui.extensionLibrary.koshienOnlyAvailableForRubyV1' } }); @@ -81,13 +76,6 @@ class ExtensionLibrary extends React.PureComponent { } } - if (id === 'koshien' && !item.disabled && this.props.rubyVersion !== '1') { - // Koshien extension is only available for Ruby v1 - // eslint-disable-next-line no-alert - alert(this.props.intl.formatMessage(messages.koshienOnlyAvailableForRubyV1)); - return; - } - if (id && !item.disabled) { if (this.props.vm.extensionManager.isExtensionLoaded(url)) { this.props.onCategorySelected(id); @@ -162,13 +150,11 @@ ExtensionLibrary.propTypes = { onToggleShowAllExtensions: PropTypes.func, showAllExtensions: PropTypes.bool, visible: PropTypes.bool, - vm: PropTypes.instanceOf(VM).isRequired, - rubyVersion: PropTypes.string + vm: PropTypes.instanceOf(VM).isRequired }; const mapStateToProps = state => ({ - showAllExtensions: state.scratchGui.extensionFilter.showAllExtensions, - rubyVersion: state.scratchGui.settings.rubyVersion + showAllExtensions: state.scratchGui.extensionFilter.showAllExtensions }); const mapDispatchToProps = dispatch => ({ diff --git a/packages/scratch-gui/src/lib/ruby-generator/koshien.js b/packages/scratch-gui/src/lib/ruby-generator/koshien.js index f103b5468c0..f5a7ce48147 100644 --- a/packages/scratch-gui/src/lib/ruby-generator/koshien.js +++ b/packages/scratch-gui/src/lib/ruby-generator/koshien.js @@ -1,3 +1,20 @@ +/** + * Render a list-typed argument for a koshien block. + * + * Lists differ between Ruby versions: + * - v1: `list("$名前")` (Smalruby3::List wrapper) + * - v2: `$名前` (plain global array variable; `list()` syntax is rejected in v2) + * @param {RubyGenerator} Generator - the RubyGenerator. + * @param {?string} listName - the resolved list name (e.g. "$名前") or null. + * @returns {string} - the argument expression, or 'nil' when no list. + */ +const koshienListArg = (Generator, listName) => { + if (!listName) { + return 'nil'; + } + return String(Generator.version) === '2' ? listName : `list(${Generator.quote_(listName)})`; +}; + /** * Define Ruby code generator for Microbit More Blocks * @param {RubyGenerator} Generator The RubyGenerator @@ -6,6 +23,13 @@ export default function (Generator) { Generator.koshien_connectGame = function (block) { const name = Generator.valueToCode(block, 'NAME', Generator.ORDER_NONE) || Generator.quote_('player1'); + // v2: ゲーム接続をイベント hat として表現し、AI 本体を do...end (サブスタック) に包む。 + // これによりクラス表現 (class < Smalruby3::Sprite) の中に配置できる。 + // v1: 従来どおりフラットな文 (Sprite.new do...end の中で逐次実行)。 + if (String(Generator.version) === '2') { + block.isStatement = true; + return `koshien.when_connect_game(name: ${name}) do\n`; + } return `koshien.connect_game(name: ${name})\n`; }; @@ -28,7 +52,7 @@ export default function (Generator) { const resultListName = Generator.listNameByName( Generator.getFieldValue(block, 'RESULT', Generator.ORDER_NONE) ); - const result = resultListName ? `list(${Generator.quote_(resultListName)})` : 'nil'; + const result = koshienListArg(Generator, resultListName); return `koshien.calc_route(result: ${result})\n`; }; @@ -39,11 +63,11 @@ export default function (Generator) { const exceptCellsListName = Generator.listNameByName( Generator.getFieldValue(block, 'EXCEPT_CELLS', Generator.ORDER_NONE) ); - const exceptCells = exceptCellsListName ? `list(${Generator.quote_(exceptCellsListName)})` : 'nil'; + const exceptCells = koshienListArg(Generator, exceptCellsListName); const resultListName = Generator.listNameByName( Generator.getFieldValue(block, 'RESULT', Generator.ORDER_NONE) ); - const result = resultListName ? `list(${Generator.quote_(resultListName)})` : 'nil'; + const result = koshienListArg(Generator, resultListName); return `koshien.calc_route(result: ${result}, src: ${src}, dst: ${dst}, except_cells: ${exceptCells})\n`; }; @@ -74,7 +98,7 @@ export default function (Generator) { const resultListName = Generator.listNameByName( Generator.getFieldValue(block, 'RESULT', Generator.ORDER_NONE) ); - const result = resultListName ? `list(${Generator.quote_(resultListName)})` : 'nil'; + const result = koshienListArg(Generator, resultListName); return `koshien.locate_objects(result: ${result}, sq_size: ${sqSize}, cent: ${position}, objects: ${objects})\n`; diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/koshien.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/koshien.js index e03f85fa2ea..6a468de93c0 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/koshien.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/koshien.js @@ -1,16 +1,69 @@ +import {convertToListBlock} from './variable-hash-ops'; +import {RubyToBlocksConverterError} from './errors'; + const Koshien = 'koshien'; +// convertToListBlock のみが参照する。version >= 2 でのみ呼ぶので実際には発火しない。 +const LIST_ARG_MESSAGES = { + arraySyntaxNotAvailableInV1: { + id: 'gui.smalruby3.rubyToBlocksConverter.koshien.arraySyntaxNotAvailableInV1', + defaultMessage: 'Array syntax is only available in Ruby version 2.', + description: 'Error when array syntax is used for a koshien list argument in Ruby version 1', + }, +}; + +// v2 ではゲーム接続をイベント hat (`koshien.when_connect_game(name:) do ... end`) で表現する。 +// フラットな `koshien.connect_game(name:)` は v1 専用 (v2 ではクラス本体に置けないため)。 +const CONNECT_GAME_MESSAGES = { + connectGameNotAvailableInV2: { + id: 'gui.smalruby3.rubyToBlocksConverter.koshien.connectGameNotAvailableInV2', + defaultMessage: + 'koshien.connect_game is only available in Ruby version 1.\n' + + 'Please use koshien.when_connect_game(name: ...) do ... end instead.', + description: 'Error when flat koshien.connect_game is used in Ruby version 2', + }, +}; + const KoshienConverter = { register: function (converter) { + // リスト引数の解決: + // - v1: `list("$名前")` … data_listcontents ブロック + // - v2: `$名前` … data_variable ブロックを data_listcontents に変換する + // 返り値 {ok, name}: ok=false は不正な引数。name=null は nil (リスト未指定)。 + const resolveListArg = block => { + if (converter.isNil(block)) { + return {ok: true, name: null}; + } + if (converter.isListBlock(block)) { + return {ok: true, name: converter.lookupListFromListBlock(block)?.name || ' '}; + } + if (String(converter.version) === '2' && converter.isVariableBlock(block)) { + const {block: listBlock, converted} = convertToListBlock(converter, LIST_ARG_MESSAGES, block); + if (converted && listBlock) { + return {ok: true, name: converter.lookupListFromListBlock(listBlock)?.name || ' '}; + } + } + return {ok: false, name: null}; + }; + converter.registerOnSend('self', Koshien, 0, params => { const {node} = params; return converter.createRubyExpressionBlock(Koshien, node); }); + // v1: フラットな `koshien.connect_game(name:)` を statement として解析。 + // v2: フラット形式はクラス本体に置けないためエラーにする (when_connect_game を使う)。 converter.registerOnSend(Koshien, 'connect_game', 1, params => { const {receiver, args} = params; + if (String(converter.version) === '2') { + throw new RubyToBlocksConverterError( + converter._context.currentNode, + converter._translator(CONNECT_GAME_MESSAGES.connectGameNotAvailableInV2), + ); + } + const name = args[0].get('sym:name'); if (!converter.isStringOrBlock(name)) return null; @@ -19,6 +72,20 @@ const KoshienConverter = { return block; }); + // v2: `koshien.when_connect_game(name:) do ... end` をイベント hat として解析。 + // do...end の本体を hat のサブスタックに取り込む。 + converter.registerOnSendWithBlock(Koshien, 'when_connect_game', 1, 0, params => { + const {receiver, args, rubyBlock} = params; + + const name = args[0].get('sym:name'); + if (!converter.isStringOrBlock(name)) return null; + + const block = converter.changeRubyExpressionBlock(receiver, 'koshien_connectGame', 'hat'); + converter.addTextInput(block, 'NAME', name, 'player1'); + converter.setParent(rubyBlock, block); + return block; + }); + const checkPosition = block => { if (converter.isBlock(block)) return true; if (!converter.isString(block)) return false; @@ -67,25 +134,28 @@ const KoshienConverter = { const result = args[0].get('sym:result'); if (!src && !dst && !exceptCells) { - if (!converter.isListBlock(result) && !converter.isNil(result)) return null; + const r = resolveListArg(result); + if (!r.ok) return null; const block = converter.changeRubyExpressionBlock(receiver, 'koshien_calcGoalRoute', 'statement'); - converter.addField(block, 'RESULT', converter.lookupListFromListBlock(result)?.name || ' '); + converter.addField(block, 'RESULT', r.name || ' '); converter.removeBlock(result); return block; } if (!checkPosition(src)) return null; if (!checkPosition(dst)) return null; - if (!converter.isListBlock(exceptCells) && !converter.isNil(exceptCells)) return null; - if (!converter.isListBlock(result) && !converter.isNil(result)) return null; + const ec = resolveListArg(exceptCells); + if (!ec.ok) return null; + const r = resolveListArg(result); + if (!r.ok) return null; const block = converter.changeRubyExpressionBlock(receiver, 'koshien_calcRoute', 'statement'); converter.addTextInput(block, 'SRC', src, '0:0'); converter.addTextInput(block, 'DST', dst, '0:0'); - converter.addField(block, 'EXCEPT_CELLS', converter.lookupListFromListBlock(exceptCells)?.name || ' '); + converter.addField(block, 'EXCEPT_CELLS', ec.name || ' '); converter.removeBlock(exceptCells); - converter.addField(block, 'RESULT', converter.lookupListFromListBlock(result)?.name || ' '); + converter.addField(block, 'RESULT', r.name || ' '); converter.removeBlock(result); return block; }); @@ -142,13 +212,14 @@ const KoshienConverter = { if (!converter.isNumberOrBlock(sqSize)) return null; if (!checkPosition(cent)) return null; if (!converter.isStringOrBlock(objects)) return null; - if (!converter.isListBlock(result) && !converter.isNil(result)) return null; + const r = resolveListArg(result); + if (!r.ok) return null; const block = converter.changeRubyExpressionBlock(receiver, 'koshien_locateObjects', 'statement'); converter.addNumberInput(block, 'SQ_SIZE', 'math_number', sqSize, 0); converter.addTextInput(block, 'POSITION', cent, '0:0'); converter.addTextInput(block, 'OBJECTS', objects, 'ABCD'); - converter.addField(block, 'RESULT', converter.lookupListFromListBlock(result)?.name || ' '); + converter.addField(block, 'RESULT', r.name || ' '); converter.removeBlock(result); return block; }); diff --git a/packages/scratch-gui/src/lib/settings/ruby-version/index.js b/packages/scratch-gui/src/lib/settings/ruby-version/index.js index e6bf14479ce..63adb729848 100644 --- a/packages/scratch-gui/src/lib/settings/ruby-version/index.js +++ b/packages/scratch-gui/src/lib/settings/ruby-version/index.js @@ -19,11 +19,6 @@ const messages = defineMessages({ defaultMessage: 'Ruby', description: 'Ruby version sub-menu' }, - koshienCannotChangeRubyVersion: { - id: 'gui.menuBar.koshienCannotChangeRubyVersion', - defaultMessage: 'The Ruby version cannot be changed when the Koshien extension is loaded.', - description: 'Alert message when trying to change Ruby version with Koshien extension' - }, cannotSwitchToV1: { id: 'gui.menuBar.cannotSwitchToV1', defaultMessage: 'Cannot switch to v1 because v2 features (module, class) are in use.' + diff --git a/packages/scratch-gui/src/locales/en.js b/packages/scratch-gui/src/locales/en.js index ad819aafc1e..46f20704590 100644 --- a/packages/scratch-gui/src/locales/en.js +++ b/packages/scratch-gui/src/locales/en.js @@ -246,7 +246,6 @@ export default { 'gui.extensionLibrary.showAllExtensions': 'Show all extensions', 'gui.extensionLibrary.meshDeprecationWarning': 'The legacy mesh extension can only be used until April 30. If you want to continue using the legacy mesh extension, select OK. Otherwise, if you want to use the new mesh extension, select Cancel.', - 'gui.extensionLibrary.koshienOnlyAvailableForRubyV1': 'The Koshien extension is only available for Ruby v1', 'gui.smalruby3.rubyToBlocksConverter.couldNotConvertPrimitive': '"{ SOURCE }" could not be converted the block.', 'gui.smalruby3.rubyToBlocksConverter.wrongInstruction': '"{ SOURCE }" is the wrong instruction.', 'gui.smalruby3.rubyToBlocksConverter.wrongInstructionInClass': @@ -327,8 +326,6 @@ export default { 'gui.menuBar.blockDisplay': 'Block Display...', 'gui.menuBar.tutorials': 'Tutorials', 'gui.menuBar.koshienEntryForm': 'Entry Form', - 'gui.menuBar.koshienCannotChangeRubyVersion': - 'The Ruby version cannot be changed when the Koshien extension is loaded.', 'gui.rubyVersion.v1': 'v1', 'gui.rubyVersion.v2': 'v2 (default)', diff --git a/packages/scratch-gui/src/locales/ja-Hira.js b/packages/scratch-gui/src/locales/ja-Hira.js index f322f0e26c5..f3f73c3872f 100644 --- a/packages/scratch-gui/src/locales/ja-Hira.js +++ b/packages/scratch-gui/src/locales/ja-Hira.js @@ -278,8 +278,6 @@ export default { 'gui.menuBar.saveAIAs': 'AIになまえをつけてほぞん...', 'gui.menuBar.testAI': 'AIをためす', 'gui.menuBar.koshienEntryForm': 'さんかもうしこみ', - 'gui.menuBar.koshienCannotChangeRubyVersion': - 'スモウルビーこうしえんかくちょうきのうがよみこまれているときは、Rubyのバージョンをへんこうできません。', 'gui.menuBar.aiSaving': 'ルビーをほぞんちゅう...', 'gui.menuBar.aiSaved': 'ルビーがほぞんされました。', 'gui.smalruby3.alerts.rubyVersionChangeFailed': @@ -750,8 +748,6 @@ export default { 'gui.extensionLibrary.showAllExtensions': 'すべてのかくちょうきのうをひょうじ', 'gui.extensionLibrary.meshDeprecationWarning': 'じゅうらいのメッシュかくちょうきのうは4がつ30にちまでしかつかえません。このままじゅうらいのメッシュかくちょうきのうをりようするばあいはOKをせんたくします。そうではなく、あたらしいメッシュかくちょうきのうをつかうばあいはキャンセルをせんたくします。', - 'gui.extensionLibrary.koshienOnlyAvailableForRubyV1': - 'スモウルビーこうしえんかくちょうきのうは Ruby v1 でのみりようできます。', // MicroBit More - Tilt gesture labels (override to match microbit extension) 'mbitMore.gesturesMenu.tiltUp': 'うえにかたむいた', diff --git a/packages/scratch-gui/src/locales/ja.js b/packages/scratch-gui/src/locales/ja.js index 44307002802..25c0bc9cd18 100644 --- a/packages/scratch-gui/src/locales/ja.js +++ b/packages/scratch-gui/src/locales/ja.js @@ -269,8 +269,6 @@ export default { 'gui.menuBar.saveAIAs': 'AIに名前をつけて保存...', 'gui.menuBar.testAI': 'AIを試す', 'gui.menuBar.koshienEntryForm': '参加申し込み', - 'gui.menuBar.koshienCannotChangeRubyVersion': - 'スモウルビー甲子園拡張機能が読み込まれているときは、Rubyのバージョンを変更できません。', 'gui.menuBar.aiSaving': 'ルビーを保存中...', 'gui.menuBar.aiSaved': 'ルビーが保存されました。', 'gui.smalruby3.alerts.rubyVersionChangeFailed': @@ -721,7 +719,6 @@ export default { 'gui.extensionLibrary.showAllExtensions': 'すべての拡張機能を表示', 'gui.extensionLibrary.meshDeprecationWarning': '従来のメッシュ拡張機能は4月30日までしか使えません。このまま従来のメッシュ拡張機能を利用する場合はOKを選択します。そうではなく、あたらしいメッシュ拡張機能を使う場合はキャンセルを選択します。', - 'gui.extensionLibrary.koshienOnlyAvailableForRubyV1': 'スモウルビー甲子園拡張機能は Ruby v1 でのみ利用できます。', // MicroBit More - Tilt gesture labels (override to match microbit extension) 'mbitMore.gesturesMenu.tiltUp': '上に傾いた', diff --git a/packages/scratch-gui/test/unit/components/mobile-drawer.test.jsx b/packages/scratch-gui/test/unit/components/mobile-drawer.test.jsx index 690ad85e63a..04ebdd78861 100644 --- a/packages/scratch-gui/test/unit/components/mobile-drawer.test.jsx +++ b/packages/scratch-gui/test/unit/components/mobile-drawer.test.jsx @@ -290,7 +290,7 @@ describe('MobileDrawer', () => { } }); - test('switching to v2 with koshien loaded shows alert', () => { + test('switching to v2 with koshien loaded is allowed (koshien supports v2)', () => { const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {}); const vm = { runtime: { targets: [] }, @@ -301,8 +301,8 @@ describe('MobileDrawer', () => { fireEvent.click(getByTestId('mobile-drawer-toggle-settings')); fireEvent.click(getByTestId('mobile-drawer-toggle-settings-ruby')); fireEvent.click(getByTestId('mobile-drawer-ruby-version-2')); - expect(alertSpy).toHaveBeenCalledTimes(1); - expect(props.onChangeRubyVersion).not.toHaveBeenCalled(); + expect(alertSpy).not.toHaveBeenCalled(); + expect(props.onChangeRubyVersion).toHaveBeenCalledWith('2'); } finally { alertSpy.mockRestore(); } diff --git a/packages/scratch-gui/test/unit/lib/ruby-roundtrip/extension_koshien_v2.test.js b/packages/scratch-gui/test/unit/lib/ruby-roundtrip/extension_koshien_v2.test.js new file mode 100644 index 00000000000..30e803d1b44 --- /dev/null +++ b/packages/scratch-gui/test/unit/lib/ruby-roundtrip/extension_koshien_v2.test.js @@ -0,0 +1,71 @@ +/** + * Ruby v2 roundtrip for Koshien (#743). + * + * v2 では: + * - ゲーム接続はイベント hat `koshien.when_connect_game(name:) do ... end` で表現し、 + * AI 本体を do...end のサブスタックに包む(クラス表現の中に置けるようにする)。 + * - フラットな `koshien.connect_game(name:)` はエラー(v1 専用)。 + * - リストはグローバル配列変数 `$名前`(`list("$名前")` ではない)。 + * + * v1 は従来どおりフラット出力(後方互換)。 + */ +import dedent from 'dedent'; +import {makeSpriteTarget, makeConverter, setupRubyGenerator, expectRoundTrip} from '../../helpers/ruby-roundtrip-helper'; + +describe('Ruby Roundtrip (v2): Koshien when_connect_game + list syntax', () => { + let target, runtime, converter; + + beforeEach(() => { + ({target, runtime} = makeSpriteTarget()); + setupRubyGenerator(); + converter = makeConverter(target, runtime, {version: 2}); + }); + + test('when_connect_game wraps the AI body; list args use $array syntax', async () => { + await expectRoundTrip( + converter, + target, + dedent` + koshien.when_connect_game(name: "player1") do + koshien.get_map_area("0:0") + koshien.calc_route(result: $最短経路) + koshien.calc_route(result: $最短経路, src: "4:5", dst: "6:7", except_cells: $通らない座標) + koshien.locate_objects(result: $地形, sq_size: 3, cent: "1:2", objects: "ABCD") + koshien.move_to($最短経路[1]) + koshien.turn_over + end + `, + null, + {version: 2} + ); + }); + + test('flat koshien.connect_game is an error in v2', async () => { + const result = await converter.targetCodeToBlocks( + target, + 'koshien.connect_game(name: "player1")' + ); + expect(result).toBeFalsy(); + expect(converter.errors.length).toBeGreaterThan(0); + expect(converter.errors[0].text).toMatch(/when_connect_game/); + }); +}); + +describe('Ruby Roundtrip (v1): Koshien stays flat (backward compatible)', () => { + test('flat koshien.connect_game parses and round-trips in v1', async () => { + const {target, runtime} = makeSpriteTarget(); + setupRubyGenerator(); + const converter = makeConverter(target, runtime, {version: 1}); + await expectRoundTrip( + converter, + target, + dedent` + koshien.connect_game(name: "player1") + koshien.get_map_area("0:0") + koshien.turn_over + `, + null, + {version: 1} + ); + }); +});