From 6b1d73f98774476482ea9fcc58881bb408bbb553 Mon Sep 17 00:00:00 2001 From: "Z. Cliffe Schreuders" Date: Wed, 26 Nov 2025 11:18:25 +0000 Subject: [PATCH] feat: Enhance objectives system with new NPC interactions and Ink dialogue integration --- .../break_escape/games_controller.rb | 11 +- app/models/break_escape/game.rb | 3 +- scenarios/test_objectives_ink/alice.ink | 88 ++++++++++++ scenarios/test_objectives_ink/alice.json | 1 + scenarios/test_objectives_ink/bob.ink | 62 ++++++++ scenarios/test_objectives_ink/bob.json | 1 + scenarios/test_objectives_ink/mission.json | 7 + .../test_objectives_ink/scenario.json.erb | 136 ++++++++++++++++++ 8 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 scenarios/test_objectives_ink/alice.ink create mode 100644 scenarios/test_objectives_ink/alice.json create mode 100644 scenarios/test_objectives_ink/bob.ink create mode 100644 scenarios/test_objectives_ink/bob.json create mode 100644 scenarios/test_objectives_ink/mission.json create mode 100644 scenarios/test_objectives_ink/scenario.json.erb diff --git a/app/controllers/break_escape/games_controller.rb b/app/controllers/break_escape/games_controller.rb index 06fc1a9..499d247 100644 --- a/app/controllers/break_escape/games_controller.rb +++ b/app/controllers/break_escape/games_controller.rb @@ -505,6 +505,9 @@ module BreakEscape contents end + # Items that are always allowed in inventory (core game mechanics) + ALWAYS_ALLOWED_ITEMS = %w[notepad].freeze + def validate_item_collectible(item) item_type = item['type'] # Use key_id for keys (more unique), fall back to id for other items @@ -513,6 +516,12 @@ module BreakEscape Rails.logger.info "[BreakEscape] validate_item_collectible: type=#{item_type}, id=#{item_id}, name=#{item_name}" + # Always allow core game items like notepad + if ALWAYS_ALLOWED_ITEMS.include?(item_type) + Rails.logger.info "[BreakEscape] Item is always allowed: #{item_type}" + return nil + end + # Check if this is a starting item first (if so, skip all other checks) is_starting_item = @game.scenario_data['startItemsInInventory']&.any? do |start_item| start_item['type'] == item_type && (start_item['id'] == item_id || start_item['name'] == item_name) @@ -742,7 +751,7 @@ module BreakEscape def compile_ink(ink_path) output_path = ink_path.to_s.gsub(/\.ink$/, '.json') - inklecate_path = Rails.root.join('bin', 'inklecate') + inklecate_path = BreakEscape::Engine.root.join('bin', 'inklecate') stdout, stderr, status = Open3.capture3( inklecate_path.to_s, diff --git a/app/models/break_escape/game.rb b/app/models/break_escape/game.rb index 7d16571..ea97a3f 100644 --- a/app/models/break_escape/game.rb +++ b/app/models/break_escape/game.rb @@ -380,7 +380,8 @@ module BreakEscape return { success: false, error: 'Object not unlocked' } end when 'npc_conversation' - unless npc_encountered?(task['targetNpc']) + target_npc = task['targetNPC'] || task['targetNpc'] + unless npc_encountered?(target_npc) return { success: false, error: 'NPC not encountered' } end when 'enter_room' diff --git a/scenarios/test_objectives_ink/alice.ink b/scenarios/test_objectives_ink/alice.ink new file mode 100644 index 0000000..c744e4b --- /dev/null +++ b/scenarios/test_objectives_ink/alice.ink @@ -0,0 +1,88 @@ +// alice.ink +// Demonstrates all three objective Ink tags: +// - #complete_task:task_id - marks a task as completed +// - #unlock_task:task_id - unlocks a locked task +// - #unlock_aim:aim_id - unlocks a locked aim +// +// IMPORTANT: Uses mission hub pattern - never uses -> END +// Instead uses #exit_conversation tag to leave the chat + +VAR alice_talked = false +VAR secret_revealed = false +VAR secret_task_done = false + +=== start === +Hey there! I'm Alice. Welcome to the objectives system test. +Player: Hi there! +-> hub + +=== hub === ++ {not alice_talked} [Nice to meet you, Alice] + -> first_meeting + ++ {alice_talked and not secret_revealed} [Tell me about the secret mission] + -> reveal_secret + ++ {secret_revealed and not secret_task_done} [I'm ready for the secret task] + -> complete_secret_task + ++ {secret_task_done} [Any final words?] + -> final_words + ++ [What can you tell me about objectives?] + -> explain_objectives + ++ [I need to go] + See you around! + #exit_conversation + -> hub + +=== first_meeting === +Great to meet you too! This task is now complete. +#complete_task:talk_to_alice +~ alice_talked = true +You should go talk to Bob next - I just unlocked that task for you. +-> hub + +=== explain_objectives === +The objectives system uses three Ink tags: +-> explain_objectives_detail + +=== explain_objectives_detail === ++ [Tell me about complete_task] + NPC: **complete_task:task_id** marks a task as completed. The ObjectivesManager will update the UI and sync with the server. + -> explain_objectives_detail + ++ [Tell me about unlock_task] + NPC: **unlock_task:task_id** unlocks a locked task so it becomes active and visible. + -> explain_objectives_detail + ++ [Tell me about unlock_aim] + NPC: **unlock_aim:aim_id** unlocks an entire aim (objective group) that was previously locked. + -> explain_objectives_detail + ++ [That's enough info, thanks] + NPC: These tags let NPCs control the player's objectives through dialogue! + -> hub + +=== reveal_secret === +Alright, I'll let you in on a secret... +There's a hidden mission that only unlocks through dialogue! +#unlock_aim:secret_mission +~ secret_revealed = true +I've just unlocked the "Secret Mission" aim for you. Check your objectives! +But the tasks inside are still locked. Let me unlock the first one... +#unlock_task:secret_task_1 +There! Now you can complete the first secret task. +-> hub + +=== complete_secret_task === +Excellent! You're doing great with the secret mission. +#complete_task:secret_task_1 +~ secret_task_done = true +That's one secret task down! Bob can help you with the second one. +-> hub + +=== final_words === +You've done great! Once Bob helps you finish, come back for the final debrief. +-> hub diff --git a/scenarios/test_objectives_ink/alice.json b/scenarios/test_objectives_ink/alice.json new file mode 100644 index 0000000..d7fd6f8 --- /dev/null +++ b/scenarios/test_objectives_ink/alice.json @@ -0,0 +1 @@ +{"inkVersion":21,"root":[[["done",{"#n":"g-0"}],null],"done",{"start":["^Hey there! I'm Alice. Welcome to the objectives system test.","\n","^Player: Hi there!","\n",{"->":"hub"},null],"hub":[["ev","str","^Nice to meet you, Alice","/str",{"VAR?":"alice_talked"},"!","/ev",{"*":".^.c-0","flg":5},"ev","str","^Tell me about the secret mission","/str",{"VAR?":"alice_talked"},{"VAR?":"secret_revealed"},"!","&&","/ev",{"*":".^.c-1","flg":5},"ev","str","^I'm ready for the secret task","/str",{"VAR?":"secret_revealed"},{"VAR?":"secret_task_done"},"!","&&","/ev",{"*":".^.c-2","flg":5},"ev","str","^Any final words?","/str",{"VAR?":"secret_task_done"},"/ev",{"*":".^.c-3","flg":5},"ev","str","^What can you tell me about objectives?","/str","/ev",{"*":".^.c-4","flg":4},"ev","str","^I need to go","/str","/ev",{"*":".^.c-5","flg":4},{"c-0":["\n",{"->":"first_meeting"},null],"c-1":["\n",{"->":"reveal_secret"},null],"c-2":["\n",{"->":"complete_secret_task"},null],"c-3":["\n",{"->":"final_words"},null],"c-4":["\n",{"->":"explain_objectives"},null],"c-5":["^ ","\n","^See you around!","\n","#","^exit_conversation","/#",{"->":"hub"},null]}],null],"first_meeting":["^Great to meet you too! This task is now complete.","\n","#","^complete_task:talk_to_alice","/#","ev",true,"/ev",{"VAR=":"alice_talked","re":true},"^You should go talk to Bob next - I just unlocked that task for you.","\n",{"->":"hub"},null],"explain_objectives":["^The objectives system uses three Ink tags:","\n",{"->":"explain_objectives_detail"},null],"explain_objectives_detail":[["ev","str","^Tell me about complete_task","/str","/ev",{"*":".^.c-0","flg":4},"ev","str","^Tell me about unlock_task","/str","/ev",{"*":".^.c-1","flg":4},"ev","str","^Tell me about unlock_aim","/str","/ev",{"*":".^.c-2","flg":4},"ev","str","^That's enough info, thanks","/str","/ev",{"*":".^.c-3","flg":4},{"c-0":["\n","^NPC: **complete_task:task_id** marks a task as completed. The ObjectivesManager will update the UI and sync with the server.","\n",{"->":".^.^.^"},null],"c-1":["\n","^NPC: **unlock_task:task_id** unlocks a locked task so it becomes active and visible.","\n",{"->":".^.^.^"},null],"c-2":["\n","^NPC: **unlock_aim:aim_id** unlocks an entire aim (objective group) that was previously locked.","\n",{"->":".^.^.^"},null],"c-3":["\n","^NPC: These tags let NPCs control the player's objectives through dialogue!","\n",{"->":"hub"},null]}],null],"reveal_secret":["^Alright, I'll let you in on a secret...","\n","^There's a hidden mission that only unlocks through dialogue!","\n","#","^unlock_aim:secret_mission","/#","ev",true,"/ev",{"VAR=":"secret_revealed","re":true},"^I've just unlocked the \"Secret Mission\" aim for you. Check your objectives!","\n","^But the tasks inside are still locked. Let me unlock the first one...","\n","#","^unlock_task:secret_task_1","/#","^There! Now you can complete the first secret task.","\n",{"->":"hub"},null],"complete_secret_task":["^Excellent! You're doing great with the secret mission.","\n","#","^complete_task:secret_task_1","/#","ev",true,"/ev",{"VAR=":"secret_task_done","re":true},"^That's one secret task down! Bob can help you with the second one.","\n",{"->":"hub"},null],"final_words":["^You've done great! Once Bob helps you finish, come back for the final debrief.","\n",{"->":"hub"},null],"global decl":["ev",false,{"VAR=":"alice_talked"},false,{"VAR=":"secret_revealed"},false,{"VAR=":"secret_task_done"},"/ev","end",null]}],"listDefs":{}} \ No newline at end of file diff --git a/scenarios/test_objectives_ink/bob.ink b/scenarios/test_objectives_ink/bob.ink new file mode 100644 index 0000000..ceedf88 --- /dev/null +++ b/scenarios/test_objectives_ink/bob.ink @@ -0,0 +1,62 @@ +// bob.ink +// Demonstrates objective Ink tags for the second NPC +// Works in conjunction with alice.ink to show task chaining +// +// IMPORTANT: Uses mission hub pattern - never uses -> END +// Instead uses #exit_conversation tag to leave the chat + +VAR bob_talked = false +VAR helped_with_secret = false +VAR secret_revealed = false // from alice.ink + +=== start === +*cough* Oh, hey. I'm Bob. Didn't see you there. +-> hub + +=== hub === ++ {not bob_talked} [Alice sent me to talk to you] + -> first_meeting + ++ {bob_talked and secret_revealed and not helped_with_secret} [Can you help with the secret task?] + -> secret_task_help + ++ {helped_with_secret} [Thanks for the help!] + -> thanks_response + ++ [What do you do here?] + -> about_bob + ++ [Goodbye] + Later. + #exit_conversation + -> hub + +=== first_meeting === +Ah, Alice sent you? Good, good. +#complete_task:talk_to_bob +~ bob_talked = true +I've just marked that task complete. +If Alice told you about anything... special... come back and ask me. +-> hub + +=== about_bob === +I handle the technical side of things. +Mostly just unlocking things that need to be unlocked. +Speaking of which... if there are any locked tasks you need help with, just ask. +-> hub + +=== secret_task_help === +The secret task, eh? Let me help you with that. +#unlock_task:secret_task_2 +#complete_task:secret_task_2 +~ helped_with_secret = true +Done! Both secret tasks are now complete. +That means the secret aim should be finished too. +#unlock_aim:finale +#unlock_task:final_debrief +I've also unlocked the finale for you. Go talk to Alice for the final debrief! +-> hub + +=== thanks_response === +No problem! Go see Alice for the final debrief. She's waiting for you. +-> hub diff --git a/scenarios/test_objectives_ink/bob.json b/scenarios/test_objectives_ink/bob.json new file mode 100644 index 0000000..0639040 --- /dev/null +++ b/scenarios/test_objectives_ink/bob.json @@ -0,0 +1 @@ +{"inkVersion":21,"root":[[["done",{"#n":"g-0"}],null],"done",{"start":[[["ev",{"^->":"start.0.0.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-0","flg":18},{"s":["^cough* Oh, hey. I'm Bob. Didn't see you there.",{"->":"$r","var":true},null]}],{"c-0":["ev",{"^->":"start.0.c-0.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.0.s"},[{"#n":"$r2"}],"\n",{"->":"hub"},{"#f":5}]}],null],"hub":[["ev","str","^Alice sent me to talk to you","/str",{"VAR?":"bob_talked"},"!","/ev",{"*":".^.c-0","flg":5},"ev","str","^Can you help with the secret task?","/str",{"VAR?":"bob_talked"},{"VAR?":"secret_revealed"},"&&",{"VAR?":"helped_with_secret"},"!","&&","/ev",{"*":".^.c-1","flg":5},"ev","str","^Thanks for the help!","/str",{"VAR?":"helped_with_secret"},"/ev",{"*":".^.c-2","flg":5},"ev","str","^What do you do here?","/str","/ev",{"*":".^.c-3","flg":4},"ev","str","^Goodbye","/str","/ev",{"*":".^.c-4","flg":4},{"c-0":["\n",{"->":"first_meeting"},null],"c-1":["\n",{"->":"secret_task_help"},null],"c-2":["\n",{"->":"thanks_response"},null],"c-3":["\n",{"->":"about_bob"},null],"c-4":["\n","^Later.","\n","#","^exit_conversation","/#",{"->":"hub"},null]}],null],"first_meeting":["^Ah, Alice sent you? Good, good.","\n","#","^complete_task:talk_to_bob","/#","ev",true,"/ev",{"VAR=":"bob_talked","re":true},"^I've just marked that task complete.","\n","^If Alice told you about anything... special... come back and ask me.","\n",{"->":"hub"},null],"about_bob":["^I handle the technical side of things.","\n","^Mostly just unlocking things that need to be unlocked.","\n","^Speaking of which... if there are any locked tasks you need help with, just ask.","\n",{"->":"hub"},null],"secret_task_help":["^The secret task, eh? Let me help you with that.","\n","#","^unlock_task:secret_task_2","/#","#","^complete_task:secret_task_2","/#","ev",true,"/ev",{"VAR=":"helped_with_secret","re":true},"^Done! Both secret tasks are now complete.","\n","^That means the secret aim should be finished too.","\n","#","^unlock_aim:finale","/#","#","^unlock_task:final_debrief","/#","^I've also unlocked the finale for you. Go talk to Alice for the final debrief!","\n",{"->":"hub"},null],"thanks_response":["^No problem! Go see Alice for the final debrief. She's waiting for you.","\n",{"->":"hub"},null],"global decl":["ev",false,{"VAR=":"bob_talked"},false,{"VAR=":"helped_with_secret"},false,{"VAR=":"secret_revealed"},"/ev","end",null]}],"listDefs":{}} \ No newline at end of file diff --git a/scenarios/test_objectives_ink/mission.json b/scenarios/test_objectives_ink/mission.json new file mode 100644 index 0000000..4af49be --- /dev/null +++ b/scenarios/test_objectives_ink/mission.json @@ -0,0 +1,7 @@ +{ + "display_name": "Test Objectives (Ink Tags)", + "description": "Demonstrates objectives controlled via Ink dialogue tags: complete_task, unlock_task, and unlock_aim.", + "difficulty_level": 1, + "secgen_scenario": null, + "collection": "testing" +} diff --git a/scenarios/test_objectives_ink/scenario.json.erb b/scenarios/test_objectives_ink/scenario.json.erb new file mode 100644 index 0000000..481b000 --- /dev/null +++ b/scenarios/test_objectives_ink/scenario.json.erb @@ -0,0 +1,136 @@ +{ + "scenario_brief": "Test scenario demonstrating Ink-based objective control. Talk to NPCs to complete, unlock, and progress objectives through dialogue.", + "endGoal": "Complete all objectives by talking to the NPCs", + "version": "1.0", + "startRoom": "lobby", + "startItemsInInventory": [ + { + "type": "notepad", + "name": "Notepad", + "takeable": true, + "readable": true, + "text": "Use this notepad to review your collected notes and observations.", + "observations": "A handy notepad for keeping track of important information." + } + ], + "objectives": [ + { + "aimId": "meet_contacts", + "title": "Meet Your Contacts", + "description": "Talk to the NPCs in the lobby to get started", + "status": "active", + "order": 0, + "tasks": [ + { + "taskId": "talk_to_alice", + "title": "Talk to Alice", + "type": "npc_conversation", + "targetNPC": "alice", + "status": "active", + "onComplete": { + "unlockTask": "talk_to_bob" + } + }, + { + "taskId": "talk_to_bob", + "title": "Talk to Bob", + "type": "npc_conversation", + "targetNPC": "bob", + "status": "locked" + } + ] + }, + { + "aimId": "secret_mission", + "title": "The Secret Mission", + "description": "A hidden aim that gets unlocked via Ink dialogue", + "status": "locked", + "order": 1, + "tasks": [ + { + "taskId": "secret_task_1", + "title": "Complete the first secret task", + "type": "npc_conversation", + "targetNPC": "alice", + "status": "locked" + }, + { + "taskId": "secret_task_2", + "title": "Complete the second secret task", + "type": "npc_conversation", + "targetNPC": "bob", + "status": "locked" + } + ] + }, + { + "aimId": "finale", + "title": "Mission Complete", + "description": "Finish up with your contacts", + "status": "locked", + "order": 2, + "tasks": [ + { + "taskId": "final_debrief", + "title": "Get the final debrief", + "type": "npc_conversation", + "targetNPC": "alice", + "status": "locked" + } + ] + } + ], + "globalVariables": { + "alice_talked": false, + "bob_talked": false, + "secret_unlocked": false + }, + "rooms": { + "lobby": { + "type": "room_reception", + "connections": {}, + "objects": [], + "npcs": [ + { + "id": "alice", + "displayName": "Alice", + "npcType": "person", + "position": { "x": 3, "y": 5 }, + "spriteSheet": "hacker", + "spriteTalk": "assets/characters/hacker-talk.png", + "spriteConfig": { + "idleFrameStart": 20, + "idleFrameEnd": 23 + }, + "storyPath": "scenarios/test_objectives_ink/alice.json", + "currentKnot": "start", + "avatar": "assets/npc/avatars/npc_alice.png", + "externalVariables": {}, + "persistentVariables": { + "alice_talked": false, + "secret_revealed": false + } + }, + { + "id": "bob", + "displayName": "Bob", + "npcType": "person", + "position": { "x": 8, "y": 5 }, + "spriteSheet": "hacker-red", + "spriteTalk": "assets/characters/hacker-red-talk.png", + "spriteConfig": { + "idleFrameStart": 20, + "idleFrameEnd": 23 + }, + "storyPath": "scenarios/test_objectives_ink/bob.json", + "currentKnot": "start", + "avatar": "assets/npc/avatars/npc_bob.png", + "externalVariables": {}, + "persistentVariables": { + "bob_talked": false + } + } + ] + } + } +}