diff --git a/non production apps/EscapeRoomApp/Docs/CHANGELOG.md b/non production apps/EscapeRoomApp/Docs/CHANGELOG.md
new file mode 100644
index 00000000..a6ff3e6a
--- /dev/null
+++ b/non production apps/EscapeRoomApp/Docs/CHANGELOG.md
@@ -0,0 +1,39 @@
+# Changelog
+
+All notable changes to the BCTalent.EscapeRoom framework are documented here.
+
+---
+
+## [1.4.1.0] - 2026-09-14
+
+### Fixed
+
+- **Two rooms could be opened / venue closed prematurely** — `UpdateStatus()` on an already *Completed* room re-ran `OpenNextRoom()` unconditionally. Because that procedure looked for the next *Locked* room, it skipped the room that was already in progress and opened the one after it; once no locked room was left it stopped the venue even though rooms were still in progress. Pressing "Update Status", "Get Hint" or "Solve" on a completed room card (reachable from the room list and from the task-completed notification) triggered it.
+ - `OpenNextRoom()` is now idempotent: it exits when a later room is already InProgress, and closes the venue only via `CloseVenueIfCompleted()` (all rooms Completed).
+ - Room, task and venue status transitions re-read the row under an update lock and re-check the status before modifying, closing the race where the same completion ran in several sessions at once (e.g. event subscribers fired from the concurrency simulations' background sessions).
+ - Room/venue state is committed before the completion image is shown, so an interrupted session can no longer leave the next room locked.
+- Renamed a local variable named `Key` in codeunit 73925 "Escape Room Telemetry" (reserved word in current AL compilers, blocked the build).
+
+### Changed
+
+- `"Escape Room Venue".Stop()` and `CloseVenueIfCompleted()` now return `Boolean` (true when the venue got completed by that call) and no longer show the completion image themselves; `"Escape Room".Stop()` shows it.
+
+---
+
+## [1.4.0.0] - 2026-06-10
+
+### Added
+
+- **Custom Telemetry Events API** — New public `LogCustomEvent` overloads on codeunit 73925 "Escape Room Telemetry" allow room extension apps to emit custom scoring and diagnostic events.
+ - Task-scoped and room-scoped variants
+ - Optional `ExtraDimensions` parameter for caller-provided custom dimensions
+ - Score clamped to -5..+5 to keep leaderboards balanced
+ - All custom events use the fixed event name `EscapeRoomCustomEvent` with `EventSource = Custom` dimension
+- **Custom Events Overview** KQL query in `LeaderboardQueries.kql` for facilitator auditing
+- All scoring KQL queries and dashboard tiles now include `EscapeRoomCustomEvent`
+
+---
+
+## [1.3.x] - Previous releases
+
+Initial framework with seven built-in telemetry events, interface-based venue/room/task system, leaderboard KQL queries, and Azure Data Explorer dashboard.
diff --git a/non production apps/EscapeRoomApp/Docs/Dev/API-Reference.md b/non production apps/EscapeRoomApp/Docs/Dev/API-Reference.md
index fca4b266..7f7e94a0 100644
--- a/non production apps/EscapeRoomApp/Docs/Dev/API-Reference.md
+++ b/non production apps/EscapeRoomApp/Docs/Dev/API-Reference.md
@@ -527,6 +527,52 @@ procedure UpdateStatus()
---
+## Telemetry API — Codeunit 73925 "Escape Room Telemetry"
+
+### Built-in Event Procedures
+
+```al
+procedure LogFinishedTask(var Task: Record "Escape Room Task")
+procedure LogHintRequested(var Task: Record "Escape Room Task")
+procedure LogSolutionRequested(var Room: record "Escape Room")
+procedure LogRoomStarted(var Room: Record "Escape Room")
+procedure LogRoomCompleted(var Room: Record "Escape Room")
+procedure LogVenueCompleted(var Venue: Record "Escape Room Venue")
+procedure LogNotification(NotificationText: Text)
+```
+
+### Custom Event Procedures
+
+```al
+/// Task-scoped custom event (standard task/room/venue dimensions added automatically)
+procedure LogCustomEvent(var EscapeRoomTask: Record "Escape Room Task"; EventId: Text; EventMessage: Text; ScorePoints: Integer)
+
+/// Room-scoped custom event (standard room/venue dimensions added automatically)
+procedure LogCustomEvent(var EscapeRoom: Record "Escape Room"; EventId: Text; EventMessage: Text; ScorePoints: Integer)
+
+/// Task-scoped custom event with additional caller-provided dimensions
+procedure LogCustomEvent(var EscapeRoomTask: Record "Escape Room Task"; EventId: Text; EventMessage: Text; ScorePoints: Integer; ExtraDimensions: Dictionary of [Text, Text])
+
+/// Room-scoped custom event with additional caller-provided dimensions
+procedure LogCustomEvent(var EscapeRoom: Record "Escape Room"; EventId: Text; EventMessage: Text; ScorePoints: Integer; ExtraDimensions: Dictionary of [Text, Text])
+```
+
+**Parameters:**
+- `EventId` — Caller-defined identifier (max 80 chars, trimmed; empty defaults to `Unspecified`). Recommend prefix with venue id, e.g. `DEV1-EasterEggFound`.
+- `EventMessage` — Human-readable message logged to Application Insights.
+- `ScorePoints` — Points added to leaderboard, clamped to -5..+5.
+- `ExtraDimensions` — Optional additional dimensions. Keys that conflict with standard dimensions are silently skipped.
+
+**Behavior:**
+- Application Insights event name is always `EscapeRoomCustomEvent`.
+- Adds `EventId` and `EventSource = Custom` dimensions automatically.
+- Standard dimensions (VenueId, VenueName, RoomName, TaskName, ScorePoints, etc.) cannot be overwritten by caller.
+- Never throws — defensive against empty records.
+
+See [Telemetry Integration](../Framework/Telemetry-Integration.md#custom-events-for-room-developers) for full documentation.
+
+---
+
## Backward Compatibility Guarantee
**Framework Version 1.x Promise:**
@@ -553,4 +599,4 @@ procedure UpdateStatus()
---
-**Last Updated:** January 7, 2026
+**Last Updated:** June 10, 2026
diff --git a/non production apps/EscapeRoomApp/Docs/Framework/Architecture.md b/non production apps/EscapeRoomApp/Docs/Framework/Architecture.md
index 3ba60807..d7b0dc13 100644
--- a/non production apps/EscapeRoomApp/Docs/Framework/Architecture.md
+++ b/non production apps/EscapeRoomApp/Docs/Framework/Architecture.md
@@ -363,9 +363,11 @@ codeunit 50103 "My Task Validation" implements "IEscape Room Task Validation"
9. Telemetry tracks all events for leaderboard
**Room Navigation:**
-- `OpenNextRoom()` procedure finds next room by sequence
-- Sets current key and ascending order for proper ordering
-- Only InProgress or NotStarted rooms can be opened
+- `OpenNextRoom()` opens the first *Locked* room with a higher sequence than the room that was just completed
+- It is idempotent: when a later room is already *InProgress* it does nothing, so calling it twice (concurrent sessions, or "Update Status" pressed on an already completed room) can never open a second room
+- When no later room is left, it calls `CloseVenueIfCompleted()`, which closes the venue only when *every* room is Completed
+- All status transitions (`Start()`, `Stop()` on rooms and tasks, `Stop()` on the venue) re-read the row under an update lock (`ReadIsolation = UpdLock`) and re-check the status before modifying, so two sessions completing the same task/room serialize instead of both performing the transition
+- State transitions are committed *before* the completion image is shown, so an interrupted or UI-less (background) session cannot leave the next room locked
---
@@ -398,12 +400,19 @@ procedure Start()
```al
procedure UpdateStatus()
-// Checks task completion and updates room status
+// Re-validates open tasks and completes the room when none remain
+// On an already completed room it only runs the (idempotent) OpenNextRoom() recovery
procedure CloseRoomIfCompleted()
-// Sets room to Completed when all tasks done
-// Opens next room automatically
-// Logs telemetry for room completion
+// Completes the room when all tasks are done (calls Stop())
+
+procedure Stop()
+// Locks the row, sets Completed, commits, logs telemetry,
+// opens the next room (or closes the venue), then shows the completion image(s)
+
+internal procedure OpenNextRoom() VenueCompleted: Boolean
+// Opens the next Locked room; no-op when a later room is InProgress
+// Returns true when the venue got completed by this call
```
### Escape Room Task Table Procedures
diff --git a/non production apps/EscapeRoomApp/Docs/Framework/Creating-Rooms.md b/non production apps/EscapeRoomApp/Docs/Framework/Creating-Rooms.md
index ce47a43b..db9bb135 100644
--- a/non production apps/EscapeRoomApp/Docs/Framework/Creating-Rooms.md
+++ b/non production apps/EscapeRoomApp/Docs/Framework/Creating-Rooms.md
@@ -1011,6 +1011,22 @@ Once your venue is created:
---
+## Custom Scoring with Telemetry
+
+Room apps can emit custom telemetry events for bonus challenges, easter eggs, or penalties using the `LogCustomEvent` overloads on codeunit 73925 "Escape Room Telemetry". Custom events carry the same standard dimensions as built-in events and participate in the leaderboard scoring pipeline (score clamped to -5..+5).
+
+```al
+var
+ EscapeRoomTelemetry: Codeunit "Escape Room Telemetry";
+begin
+ EscapeRoomTelemetry.LogCustomEvent(Task, 'DEV1-BonusChallenge', 'Participant completed the bonus challenge.', 2);
+end;
+```
+
+See [Telemetry Integration - Custom Events](Telemetry-Integration.md#custom-events-for-room-developers) for the full API reference, clamping rules, and EventId conventions.
+
+---
+
## Related Documentation
- [Architecture Overview](Architecture.md) - Framework design patterns
@@ -1020,4 +1036,4 @@ Once your venue is created:
---
-**Last Updated:** January 7, 2026
+**Last Updated:** June 10, 2026
diff --git a/non production apps/EscapeRoomApp/Docs/Framework/Telemetry-Integration.md b/non production apps/EscapeRoomApp/Docs/Framework/Telemetry-Integration.md
index e9ef9434..f27142d5 100644
--- a/non production apps/EscapeRoomApp/Docs/Framework/Telemetry-Integration.md
+++ b/non production apps/EscapeRoomApp/Docs/Framework/Telemetry-Integration.md
@@ -110,6 +110,24 @@ The framework logs seven distinct event types:
---
+#### 8. **EscapeRoomCustomEvent**
+
+**When:** Room extension app calls `LogCustomEvent(...)` to emit a custom scoring or diagnostic event
+**Score Impact:** -5 to +5 points (caller-defined, clamped)
+**Custom Dimensions:**
+- VenueId, VenueName
+- PartnerName, FullName
+- RoomName
+- TaskName (when task-scoped)
+- ScorePoints (clamped to -5..+5)
+- EventId (caller-provided identifier, max 80 chars)
+- EventSource: `Custom`
+- Any additional caller-provided dimensions (cannot overwrite standard keys)
+
+**Logged By:** `LogCustomEvent(...)` overloads
+
+---
+
## Scoring System
### Point Values
@@ -122,6 +140,7 @@ The framework logs seven distinct event types:
| Request hint | **-1** | EscapeRoomHintRequested |
| View solution | **-3** | EscapeRoomSolutionRequested |
| Start room | **0** | EscapeRoomStarted |
+| Custom event | **-5..+5** (caller-defined) | EscapeRoomCustomEvent |
### Scoring Examples
@@ -159,6 +178,10 @@ procedure LogRoomStarted(var Room: Record "Escape Room")
procedure LogRoomCompleted(var Room: Record "Escape Room")
procedure LogVenueCompleted(var Venue: Record "Escape Room Venue")
procedure LogNotification(NotificationText: Text)
+procedure LogCustomEvent(var EscapeRoomTask: Record "Escape Room Task"; EventId: Text; EventMessage: Text; ScorePoints: Integer)
+procedure LogCustomEvent(var EscapeRoom: Record "Escape Room"; EventId: Text; EventMessage: Text; ScorePoints: Integer)
+procedure LogCustomEvent(var EscapeRoomTask: Record "Escape Room Task"; EventId: Text; EventMessage: Text; ScorePoints: Integer; ExtraDimensions: Dictionary of [Text, Text])
+procedure LogCustomEvent(var EscapeRoom: Record "Escape Room"; EventId: Text; EventMessage: Text; ScorePoints: Integer; ExtraDimensions: Dictionary of [Text, Text])
```
**Internal Procedures:**
@@ -221,6 +244,54 @@ Every telemetry event includes rich context:
---
+## Custom Events (for room developers)
+
+Room extension apps can emit custom telemetry events using the `LogCustomEvent` overloads on codeunit 73925. Custom events participate in the standard leaderboard scoring pipeline.
+
+### API Signatures
+
+```al
+// Task-scoped (includes task + room + venue dimensions)
+procedure LogCustomEvent(var EscapeRoomTask: Record "Escape Room Task"; EventId: Text; EventMessage: Text; ScorePoints: Integer)
+procedure LogCustomEvent(var EscapeRoomTask: Record "Escape Room Task"; EventId: Text; EventMessage: Text; ScorePoints: Integer; ExtraDimensions: Dictionary of [Text, Text])
+
+// Room-scoped (includes room + venue dimensions)
+procedure LogCustomEvent(var EscapeRoom: Record "Escape Room"; EventId: Text; EventMessage: Text; ScorePoints: Integer)
+procedure LogCustomEvent(var EscapeRoom: Record "Escape Room"; EventId: Text; EventMessage: Text; ScorePoints: Integer; ExtraDimensions: Dictionary of [Text, Text])
+```
+
+### Usage Example
+
+```al
+// Award a bonus point when participant discovers an easter egg
+procedure OnEasterEggFound(var Task: Record "Escape Room Task")
+var
+ EscapeRoomTelemetry: Codeunit "Escape Room Telemetry";
+begin
+ EscapeRoomTelemetry.LogCustomEvent(
+ Task,
+ 'DEV1-EasterEggFound',
+ 'Participant discovered the hidden easter egg in Room 3.',
+ 2 // +2 bonus points
+ );
+end;
+```
+
+### Rules
+
+- **Score clamping:** `ScorePoints` is clamped to **-5..+5**. Values outside this range are silently clamped to the nearest bound.
+- **EventId convention:** Prefix with your venue/app id, e.g. `DEV1-EasterEggFound`. Max 80 characters; empty defaults to `Unspecified`.
+- **Event name:** All custom events use the fixed Application Insights message `EscapeRoomCustomEvent`. The caller's `EventId` is a custom dimension — not the event name.
+- **EventSource dimension:** Always set to `Custom` to distinguish from built-in framework events.
+- **No overwriting standard dimensions:** Caller-provided `ExtraDimensions` cannot overwrite standard keys (`VenueId`, `VenueName`, `RoomName`, `TaskName`, `ScorePoints`, `EventId`, `EventSource`, etc.). Conflicting keys are silently skipped.
+- **Never throws:** The telemetry call is defensive — empty records produce empty dimension values, same as built-in behavior.
+
+### Leaderboard Impact
+
+Custom events flow into the standard leaderboard queries. The `ScorePoints` dimension is summed alongside all other scoring events. Use the "Custom Events Overview" query in `LeaderboardQueries.kql` to audit custom scoring during events.
+
+---
+
## Leaderboard Queries
### KQL Queries
@@ -446,4 +517,4 @@ end;
---
-**Last Updated:** January 7, 2026
+**Last Updated:** June 10, 2026
diff --git a/non production apps/EscapeRoomApp/Leaderboard/LeaderboardQueries.kql b/non production apps/EscapeRoomApp/Leaderboard/LeaderboardQueries.kql
index e41852e5..e4179222 100644
--- a/non production apps/EscapeRoomApp/Leaderboard/LeaderboardQueries.kql
+++ b/non production apps/EscapeRoomApp/Leaderboard/LeaderboardQueries.kql
@@ -51,6 +51,7 @@ EscapeRoomData
VenuesCompleted = countif(eventId == "ALEscapeRoomVenueCompleted"),
HintsUsed = countif(eventId == "ALEscapeRoomHintRequested"),
SolutionsUsed = countif(eventId == "ALEscapeRoomSolutionRequested"),
+ CustomEvents = countif(eventId == "ALEscapeRoomCustomEvent"),
LastActivity = max(taskStopDateTime),
FastestVenueCompletion = min(VenueCompletionTimeMinutes)
by Attendee, userId, Partner, environmentName
@@ -83,12 +84,14 @@ EscapeRoomData
VenueBonusPoints = sumif(Score, eventId == "ALEscapeRoomVenueCompleted"),
HintPenalty = sumif(Score, eventId == "ALEscapeRoomHintRequested"),
SolutionPenalty = sumif(Score, eventId == "ALEscapeRoomSolutionRequested"),
+ CustomEventPoints = sumif(Score, eventId == "ALEscapeRoomCustomEvent"),
TotalScore = sum(Score),
TasksCompleted = countif(eventId == "ALEscapeRoomTaskFinished"),
RoomsCompleted = countif(eventId == "ALEscapeRoomCompleted"),
VenuesCompleted = countif(eventId == "ALEscapeRoomVenueCompleted"),
HintsUsed = countif(eventId == "ALEscapeRoomHintRequested"),
SolutionsUsed = countif(eventId == "ALEscapeRoomSolutionRequested"),
+ CustomEvents = countif(eventId == "ALEscapeRoomCustomEvent"),
FastestVenueCompletion = min(VenueCompletionTimeMinutes)
by Attendee, Partner, environmentName
| extend
@@ -107,11 +110,13 @@ EscapeRoomData
VenueBonusPoints,
HintPenalty,
SolutionPenalty,
+ CustomEventPoints,
ScoreEfficiency,
CompletionTimeMinutes,
TasksCompleted,
HintsUsed,
- SolutionsUsed
+ SolutionsUsed,
+ CustomEvents
| order by TotalScore desc, CompletionTimeMinutes asc nulls last
// =================================================================
@@ -139,4 +144,22 @@ EscapeRoomData
CompletionTimeMinutes,
FinalScore,
CompletionDate
-| order by CompletionTimeMinutes asc
\ No newline at end of file
+| order by CompletionTimeMinutes asc
+
+// =================================================================
+// Query 4: Custom Events Overview
+// =================================================================
+EscapeRoomData
+| where eventId == "ALEscapeRoomCustomEvent"
+| extend
+ CustomEventId = tostring(customDimensions.alEventId),
+ EventSource = tostring(customDimensions.alEventSource)
+| project
+ timestamp,
+ Attendee,
+ Partner,
+ venueId,
+ roomName,
+ CustomEventId,
+ Score
+| order by timestamp desc
\ No newline at end of file
diff --git a/non production apps/EscapeRoomApp/Leaderboard/LeaderboardSetup.md b/non production apps/EscapeRoomApp/Leaderboard/LeaderboardSetup.md
index 0c061052..211da722 100644
--- a/non production apps/EscapeRoomApp/Leaderboard/LeaderboardSetup.md
+++ b/non production apps/EscapeRoomApp/Leaderboard/LeaderboardSetup.md
@@ -173,6 +173,11 @@ The queries work with these primary event types:
- **`ALEscapeRoomHintRequested`**: Hint usage with penalty
- **`ALEscapeRoomSolutionRequested`**: Solution usage with penalty
- **`ALEscapeRoomStarted`**: Room/venue start tracking
+- **`ALEscapeRoomCustomEvent`**: Custom events emitted by room extensions (score -5..+5)
+
+### Custom Events & Scoring
+
+Room extension apps can emit custom telemetry events that participate in leaderboard scoring. These events use the `EscapeRoomCustomEvent` event name and carry a caller-defined score (clamped to -5..+5). Use the **"Custom Events Overview"** query in `LeaderboardQueries.kql` to audit custom scoring during an event — it lists timestamp, attendee, partner, venue, room, EventId, and ScorePoints for all custom events.
### Calculated Fields
- **`durationMinutes`**: Calculated from start/stop timestamps
diff --git a/non production apps/EscapeRoomApp/Leaderboard/dashboard-BCTalent.EscapeRooms.json b/non production apps/EscapeRoomApp/Leaderboard/dashboard-BCTalent.EscapeRooms.json
index b5c8abe4..d40cdcaa 100644
--- a/non production apps/EscapeRoomApp/Leaderboard/dashboard-BCTalent.EscapeRooms.json
+++ b/non production apps/EscapeRoomApp/Leaderboard/dashboard-BCTalent.EscapeRooms.json
@@ -684,7 +684,7 @@
"kind": "inline",
"dataSourceId": "803c0786-1793-49b9-8c52-5d977ac0c74c"
},
- "text": "EscapeRoomData\n| where isnotempty(Attendee) and isnotempty(Score) and isnotempty( Partner)\n| extend AttendeePartner = strcat(Attendee,\" (\",Partner,\")\")\n| summarize \n TotalScore = sum(Score),\n TasksCompleted = countif(eventId == \"ALEscapeRoomTaskFinished\"),\n RoomsCompleted = countif(eventId == \"ALEscapeRoomCompleted\"),\n VenuesCompleted = countif(eventId == \"ALEscapeRoomVenueCompleted\"),\n HintsUsed = countif(eventId == \"ALEscapeRoomHintRequested\"),\n SolutionsUsed = countif(eventId == \"ALEscapeRoomSolutionRequested\"),\n LastActivity = max(taskStopDateTime)\n by AttendeePartner, userId, Partner, environmentName\n| order by TotalScore desc\n| extend Rank = row_number()\n| where Rank <= 10\n| project \n Rank,\n AttendeePartner,\n Partner,\n TotalScore,\n TasksCompleted,\n RoomsCompleted,\n VenuesCompleted,\n HintsUsed,\n SolutionsUsed,\n LastActivity\n| order by TotalScore desc",
+ "text": "EscapeRoomData\n| where isnotempty(Attendee) and isnotempty(Score) and isnotempty( Partner)\n| extend AttendeePartner = strcat(Attendee,\" (\",Partner,\")\")\n| summarize \n TotalScore = sum(Score),\n TasksCompleted = countif(eventId == \"ALEscapeRoomTaskFinished\"),\n RoomsCompleted = countif(eventId == \"ALEscapeRoomCompleted\"),\n VenuesCompleted = countif(eventId == \"ALEscapeRoomVenueCompleted\"),\n HintsUsed = countif(eventId == \"ALEscapeRoomHintRequested\"),\n SolutionsUsed = countif(eventId == \"ALEscapeRoomSolutionRequested\"),\n CustomEvents = countif(eventId == \"ALEscapeRoomCustomEvent\"),\n LastActivity = max(taskStopDateTime)\n by AttendeePartner, userId, Partner, environmentName\n| order by TotalScore desc\n| extend Rank = row_number()\n| where Rank <= 10\n| project \n Rank,\n AttendeePartner,\n Partner,\n TotalScore,\n TasksCompleted,\n RoomsCompleted,\n VenuesCompleted,\n HintsUsed,\n SolutionsUsed,\n CustomEvents,\n LastActivity\n| order by TotalScore desc",
"id": "8a6d557a-34b9-4cad-8f4b-82a33fb0b883",
"usedVariables": [
"EscapeRoomData"
@@ -706,7 +706,7 @@
"kind": "inline",
"dataSourceId": "803c0786-1793-49b9-8c52-5d977ac0c74c"
},
- "text": "EscapeRoomData\n| where isnotempty(Attendee) and isnotempty(Score) and isnotempty(Partner)\n| summarize \n TaskPoints = sumif(Score, eventId == \"ALEscapeRoomTaskFinished\"),\n RoomBonusPoints = sumif(Score, eventId == \"ALEscapeRoomCompleted\"),\n VenueBonusPoints = sumif(Score, eventId == \"ALEscapeRoomVenueCompleted\"),\n HintPenalty = sumif(Score, eventId == \"ALEscapeRoomHintRequested\"),\n SolutionPenalty = sumif(Score, eventId == \"ALEscapeRoomSolutionRequested\"),\n TotalScore = sum(Score),\n TasksCompleted = countif(eventId == \"ALEscapeRoomTaskFinished\"),\n RoomsCompleted = countif(eventId == \"ALEscapeRoomCompleted\"),\n VenuesCompleted = countif(eventId == \"ALEscapeRoomVenueCompleted\"),\n HintsUsed = countif(eventId == \"ALEscapeRoomHintRequested\"),\n SolutionsUsed = countif(eventId == \"ALEscapeRoomSolutionRequested\"),\n FastestVenueCompletion = min(VenueCompletionTimeMinutes)\n by Attendee, Partner, environmentName\n| extend \n ScoreEfficiency = iif((TasksCompleted + HintsUsed + SolutionsUsed) > 0, round((todouble(TotalScore) / (TasksCompleted + HintsUsed + SolutionsUsed)) * 100) / 100, todouble(0)),\n CompletionTimeMinutes = iff(isnull(FastestVenueCompletion), real(null), round(FastestVenueCompletion, 2))\n| order by TotalScore desc, CompletionTimeMinutes asc nulls last\n| extend Rank = row_number()\n| where Rank <= 10\n| project \n Rank,\n Attendee,\n Partner,\n TotalScore,\n TaskPoints,\n RoomBonusPoints,\n VenueBonusPoints,\n HintPenalty,\n SolutionPenalty,\n ScoreEfficiency,\n CompletionTimeMinutes,\n TasksCompleted,\n HintsUsed,\n SolutionsUsed\n| order by TotalScore desc, CompletionTimeMinutes asc nulls last\n",
+ "text": "EscapeRoomData\n| where isnotempty(Attendee) and isnotempty(Score) and isnotempty(Partner)\n| summarize \n TaskPoints = sumif(Score, eventId == \"ALEscapeRoomTaskFinished\"),\n RoomBonusPoints = sumif(Score, eventId == \"ALEscapeRoomCompleted\"),\n VenueBonusPoints = sumif(Score, eventId == \"ALEscapeRoomVenueCompleted\"),\n HintPenalty = sumif(Score, eventId == \"ALEscapeRoomHintRequested\"),\n SolutionPenalty = sumif(Score, eventId == \"ALEscapeRoomSolutionRequested\"),\n CustomEventPoints = sumif(Score, eventId == \"ALEscapeRoomCustomEvent\"),\n TotalScore = sum(Score),\n TasksCompleted = countif(eventId == \"ALEscapeRoomTaskFinished\"),\n RoomsCompleted = countif(eventId == \"ALEscapeRoomCompleted\"),\n VenuesCompleted = countif(eventId == \"ALEscapeRoomVenueCompleted\"),\n HintsUsed = countif(eventId == \"ALEscapeRoomHintRequested\"),\n SolutionsUsed = countif(eventId == \"ALEscapeRoomSolutionRequested\"),\n CustomEvents = countif(eventId == \"ALEscapeRoomCustomEvent\"),\n FastestVenueCompletion = min(VenueCompletionTimeMinutes)\n by Attendee, Partner, environmentName\n| extend \n ScoreEfficiency = iif((TasksCompleted + HintsUsed + SolutionsUsed) > 0, round((todouble(TotalScore) / (TasksCompleted + HintsUsed + SolutionsUsed)) * 100) / 100, todouble(0)),\n CompletionTimeMinutes = iff(isnull(FastestVenueCompletion), real(null), round(FastestVenueCompletion, 2))\n| order by TotalScore desc, CompletionTimeMinutes asc nulls last\n| extend Rank = row_number()\n| where Rank <= 10\n| project \n Rank,\n Attendee,\n Partner,\n TotalScore,\n TaskPoints,\n RoomBonusPoints,\n VenueBonusPoints,\n HintPenalty,\n SolutionPenalty,\n CustomEventPoints,\n ScoreEfficiency,\n CompletionTimeMinutes,\n TasksCompleted,\n HintsUsed,\n SolutionsUsed,\n CustomEvents\n| order by TotalScore desc, CompletionTimeMinutes asc nulls last\n",
"id": "e56b8546-96f6-4a9a-b0d8-6fdf90363676",
"usedVariables": [
"EscapeRoomData"
@@ -717,7 +717,7 @@
"kind": "inline",
"dataSourceId": "803c0786-1793-49b9-8c52-5d977ac0c74c"
},
- "text": "EscapeRoomData\n| where isnotempty(Attendee) and isnotempty(Score) and isnotempty(Partner)\n| summarize \n TotalScore = sum(Score),\n TasksCompleted = countif(eventId == \"ALEscapeRoomTaskFinished\"),\n RoomsCompleted = countif(eventId == \"ALEscapeRoomCompleted\"),\n VenuesCompleted = countif(eventId == \"ALEscapeRoomVenueCompleted\"),\n HintsUsed = countif(eventId == \"ALEscapeRoomHintRequested\"),\n SolutionsUsed = countif(eventId == \"ALEscapeRoomSolutionRequested\"),\n LastActivity = max(taskStopDateTime),\n FastestVenueCompletion = min(VenueCompletionTimeMinutes)\n by Attendee, userId, Partner, environmentName\n| extend CompletionTimeMinutes = iff(isnull(FastestVenueCompletion), real(null), round(FastestVenueCompletion, 2))\n| order by TotalScore desc, CompletionTimeMinutes asc nulls last\n| extend Rank = row_number()\n| where Rank <= 100\n| project \n Rank,\n Attendee,\n Partner,\n TotalScore,\n TasksCompleted,\n RoomsCompleted,\n VenuesCompleted,\n HintsUsed,\n SolutionsUsed,\n CompletionTimeMinutes,\n LastActivity\n| order by TotalScore desc, CompletionTimeMinutes asc nulls last",
+ "text": "EscapeRoomData\n| where isnotempty(Attendee) and isnotempty(Score) and isnotempty(Partner)\n| summarize \n TotalScore = sum(Score),\n TasksCompleted = countif(eventId == \"ALEscapeRoomTaskFinished\"),\n RoomsCompleted = countif(eventId == \"ALEscapeRoomCompleted\"),\n VenuesCompleted = countif(eventId == \"ALEscapeRoomVenueCompleted\"),\n HintsUsed = countif(eventId == \"ALEscapeRoomHintRequested\"),\n SolutionsUsed = countif(eventId == \"ALEscapeRoomSolutionRequested\"),\n CustomEvents = countif(eventId == \"ALEscapeRoomCustomEvent\"),\n LastActivity = max(taskStopDateTime),\n FastestVenueCompletion = min(VenueCompletionTimeMinutes)\n by Attendee, userId, Partner, environmentName\n| extend CompletionTimeMinutes = iff(isnull(FastestVenueCompletion), real(null), round(FastestVenueCompletion, 2))\n| order by TotalScore desc, CompletionTimeMinutes asc nulls last\n| extend Rank = row_number()\n| where Rank <= 100\n| project \n Rank,\n Attendee,\n Partner,\n TotalScore,\n TasksCompleted,\n RoomsCompleted,\n VenuesCompleted,\n HintsUsed,\n SolutionsUsed,\n CustomEvents,\n CompletionTimeMinutes,\n LastActivity\n| order by TotalScore desc, CompletionTimeMinutes asc nulls last",
"id": "bc7c2e02-ff93-443d-93c0-a1e0db76e80c",
"usedVariables": [
"EscapeRoomData"
diff --git a/non production apps/EscapeRoomApp/Src/1.Venue/EscapeRoomVenue.Table.al b/non production apps/EscapeRoomApp/Src/1.Venue/EscapeRoomVenue.Table.al
index 871dc2d7..e8335608 100644
--- a/non production apps/EscapeRoomApp/Src/1.Venue/EscapeRoomVenue.Table.al
+++ b/non production apps/EscapeRoomApp/Src/1.Venue/EscapeRoomVenue.Table.al
@@ -92,19 +92,34 @@ table 73926 "Escape Room Venue"
Rec.StartFirstRoom();
end;
- procedure Stop()
+ ///
+ /// Marks the venue as completed. Idempotent: a venue that already has a Stop DateTime is left alone.
+ /// Showing the completion image is up to the caller (see "Escape Room".Stop()).
+ ///
+ /// True when the venue got completed by this call.
+ procedure Stop(): Boolean
var
- Venue: Interface iEscapeRoomVenue;
- EscapeRoomNotifications: Codeunit EscapeRoomNotifications;
EscapeRoomTelemetry: Codeunit "Escape Room Telemetry";
begin
+ if not LockAndRefresh() then exit(false);
+ if Rec."Stop DateTime" <> 0DT then exit(false);
+
Rec."Stop DateTime" := CurrentDateTime();
Rec.Modify();
+ Commit();
- Commit;
-
- EscapeRoomNotifications.venueFinished(Rec);
EscapeRoomTelemetry.LogVenueCompleted(Rec);
+ exit(true);
+ end;
+
+ local procedure LockAndRefresh(): Boolean
+ var
+ Found: Boolean;
+ begin
+ Rec.ReadIsolation := IsolationLevel::UpdLock;
+ Found := Rec.Find('=');
+ Rec.ReadIsolation := IsolationLevel::Default;
+ exit(Found);
end;
procedure StartFirstRoom()
@@ -119,15 +134,16 @@ table 73926 "Escape Room Venue"
Room.Start();
end;
- procedure CloseVenueIfCompleted()
+ /// True when the venue got completed by this call.
+ procedure CloseVenueIfCompleted(): Boolean
var
Room: Record "Escape Room";
begin
Room.Setrange("Venue Id", Rec.Id);
Room.SetFilter(Status, '<>%1', Room.Status::Completed);
- if not Room.IsEmpty then exit;
+ if not Room.IsEmpty then exit(false);
- Rec.Stop();
+ exit(Rec.Stop());
end;
procedure RefreshRooms()
diff --git a/non production apps/EscapeRoomApp/Src/2.Room/EscapeRoom.Table.al b/non production apps/EscapeRoomApp/Src/2.Room/EscapeRoom.Table.al
index befb5d9b..37b9b333 100644
--- a/non production apps/EscapeRoomApp/Src/2.Room/EscapeRoom.Table.al
+++ b/non production apps/EscapeRoomApp/Src/2.Room/EscapeRoom.Table.al
@@ -89,9 +89,18 @@ table 73920 "Escape Room"
procedure UpdateStatus()
var
Task: Record "Escape Room Task";
+ Venue: Record "Escape Room Venue";
+ EscapeRoomNotifications: Codeunit EscapeRoomNotifications;
begin
if Rec.Status = Rec.Status::Completed then begin
- OpenNextRoom();
+ // Recovery path only: if this room was completed but the next room never got started
+ // (e.g. the session was interrupted), open it now. OpenNextRoom() is idempotent and does
+ // nothing while a later room is already in progress, so pressing "Update Status" on a
+ // completed room can never open a second room or close the venue prematurely.
+ if OpenNextRoom() then begin
+ Venue.Get(Rec."Venue Id");
+ EscapeRoomNotifications.VenueFinished(Venue);
+ end;
exit;
end;
@@ -113,8 +122,6 @@ table 73920 "Escape Room"
if not Task.IsEmpty() then exit;
Rec.Stop();
-
- OpenNextRoom();
end;
procedure CloseRoomIfCompleted()
@@ -129,59 +136,102 @@ table 73920 "Escape Room"
if not task.IsEmpty then exit;
Rec.Stop();
-
- OpenNextRoom();
end;
- internal procedure OpenNextRoom()
+ ///
+ /// Opens the next locked room after this one, or closes the venue when every room is completed.
+ /// Idempotent: does nothing when a later room is already in progress.
+ ///
+ /// True when the venue got completed by this call.
+ internal procedure OpenNextRoom() VenueCompleted: Boolean
var
NextRoom: Record "Escape Room";
Venue: Record "Escape Room Venue";
begin
+ NextRoom.ReadIsolation := IsolationLevel::UpdLock;
NextRoom.SetCurrentKey(Sequence);
NextRoom.Ascending := true;
NextRoom.SetRange("Venue Id", Rec."Venue Id");
NextRoom.SetFilter(Sequence, '>%1', Rec.Sequence);
+
+ // A later room is already open: nothing to do. This is what prevents a second room from
+ // being opened when this procedure runs twice (concurrent sessions, or "Update Status"
+ // pressed on an already completed room).
+ NextRoom.SetRange(Status, NextRoom.Status::InProgress);
+ if NextRoom.FindFirst() then
+ exit(false);
+
NextRoom.SetRange(Status, NextRoom.Status::Locked);
if NextRoom.FindFirst() then begin
NextRoom.Start();
- end
- else begin
- Venue.Get(Rec."Venue Id");
- Venue.Stop();
+ exit(false);
end;
+
+ // No later room left to open. Close the venue, but only when every room is really completed.
+ Venue.Get(Rec."Venue Id");
+ exit(Venue.CloseVenueIfCompleted());
end;
procedure Start()
var
EscapeRoomTelemetry: Codeunit "Escape Room Telemetry";
begin
- if Rec.Status = Rec.Status::Locked then begin
- Rec.Status := Rec.Status::InProgress;
- Rec."Start DateTime" := CurrentDateTime();
- Rec.Modify();
- Commit();
+ if not LockAndRefresh() then exit;
+ if Rec.Status <> Rec.Status::Locked then exit;
- EscapeRoomTelemetry.LogRoomStarted(Rec);
- end;
+ Rec.Status := Rec.Status::InProgress;
+ Rec."Start DateTime" := CurrentDateTime();
+ Rec.Modify();
+ Commit();
+
+ EscapeRoomTelemetry.LogRoomStarted(Rec);
end;
+ ///
+ /// Completes this room, opens the next one and then shows the completion image(s).
+ /// The state transitions are committed before any UI is shown, so an interrupted or
+ /// UI-less (background) session can no longer leave the next room locked.
+ ///
procedure Stop()
var
+ Venue: Record "Escape Room Venue";
EscapeRoomNotifications: Codeunit EscapeRoomNotifications;
EscapeRoomTelemetry: Codeunit "Escape Room Telemetry";
+ VenueCompleted: Boolean;
begin
- if Rec.Status = Rec.Status::InProgress then begin
- Rec.Status := Rec.Status::Completed;
- Rec."Stop DateTime" := CurrentDateTime();
- Rec.Modify();
- Commit();
-
- EscapeRoomNotifications.RoomFinished(Rec);
- EscapeRoomTelemetry.LogRoomCompleted(Rec);
+ if not LockAndRefresh() then exit;
+ if Rec.Status <> Rec.Status::InProgress then exit;
+
+ Rec.Status := Rec.Status::Completed;
+ Rec."Stop DateTime" := CurrentDateTime();
+ Rec.Modify();
+ Commit();
+
+ EscapeRoomTelemetry.LogRoomCompleted(Rec);
+
+ VenueCompleted := OpenNextRoom();
+
+ EscapeRoomNotifications.RoomFinished(Rec);
+ if VenueCompleted then begin
+ Venue.Get(Rec."Venue Id");
+ EscapeRoomNotifications.VenueFinished(Venue);
end;
end;
+ ///
+ /// Re-reads this room from the database while taking an update lock on its row, so that
+ /// concurrent sessions serialize on the status transition instead of both performing it.
+ ///
+ local procedure LockAndRefresh(): Boolean
+ var
+ Found: Boolean;
+ begin
+ Rec.ReadIsolation := IsolationLevel::UpdLock;
+ Found := Rec.Find('=');
+ Rec.ReadIsolation := IsolationLevel::Default;
+ exit(Found);
+ end;
+
procedure GetHint()
var
TaskRec: Record "Escape Room Task";
diff --git a/non production apps/EscapeRoomApp/Src/3.Task/EscapeRoomTask.Table.al b/non production apps/EscapeRoomApp/Src/3.Task/EscapeRoomTask.Table.al
index 3e794eb6..dc7a145b 100644
--- a/non production apps/EscapeRoomApp/Src/3.Task/EscapeRoomTask.Table.al
+++ b/non production apps/EscapeRoomApp/Src/3.Task/EscapeRoomTask.Table.al
@@ -107,14 +107,27 @@ table 73922 "Escape Room Task"
EscapeRoomNotifications: Codeunit EscapeRoomNotifications;
EscapeRoomTelemetry: Codeunit "Escape Room Telemetry";
begin
- if Rec.Status = Rec.Status::Open then begin
- Rec.Status := Rec.Status::Completed;
- Rec."Stop DateTime" := CurrentDateTime();
- Rec.Modify();
- Commit();
+ // Re-read under an update lock: task completion is often triggered from event subscribers
+ // that fire in several sessions at once (e.g. the concurrency simulations).
+ if not LockAndRefresh() then exit;
+ if Rec.Status <> Rec.Status::Open then exit;
- EscapeRoomNotifications.TaskFinished(Rec);
- EscapeRoomTelemetry.LogFinishedTask(Rec);
- end
+ Rec.Status := Rec.Status::Completed;
+ Rec."Stop DateTime" := CurrentDateTime();
+ Rec.Modify();
+ Commit();
+
+ EscapeRoomTelemetry.LogFinishedTask(Rec);
+ EscapeRoomNotifications.TaskFinished(Rec);
+ end;
+
+ local procedure LockAndRefresh(): Boolean
+ var
+ Found: Boolean;
+ begin
+ Rec.ReadIsolation := IsolationLevel::UpdLock;
+ Found := Rec.Find('=');
+ Rec.ReadIsolation := IsolationLevel::Default;
+ exit(Found);
end;
}
\ No newline at end of file
diff --git a/non production apps/EscapeRoomApp/Src/Telemetry/EscapeRoomTelemetry.Codeunit.al b/non production apps/EscapeRoomApp/Src/Telemetry/EscapeRoomTelemetry.Codeunit.al
index 79519b36..e7534970 100644
--- a/non production apps/EscapeRoomApp/Src/Telemetry/EscapeRoomTelemetry.Codeunit.al
+++ b/non production apps/EscapeRoomApp/Src/Telemetry/EscapeRoomTelemetry.Codeunit.al
@@ -97,6 +97,86 @@ codeunit 73925 "Escape Room Telemetry"
);
end;
+ /// Log a custom telemetry event scoped to a task. Standard task/room/venue dimensions are added automatically.
+ procedure LogCustomEvent(var EscapeRoomTask: Record "Escape Room Task"; EventId: Text; EventMessage: Text; ScorePoints: Integer)
+ var
+ ExtraDimensions: Dictionary of [Text, Text];
+ begin
+ LogCustomEvent(EscapeRoomTask, EventId, EventMessage, ScorePoints, ExtraDimensions);
+ end;
+
+ /// Log a custom telemetry event scoped to a room. Standard room/venue dimensions are added automatically.
+ procedure LogCustomEvent(var EscapeRoom: Record "Escape Room"; EventId: Text; EventMessage: Text; ScorePoints: Integer)
+ var
+ ExtraDimensions: Dictionary of [Text, Text];
+ begin
+ LogCustomEvent(EscapeRoom, EventId, EventMessage, ScorePoints, ExtraDimensions);
+ end;
+
+ /// Task-scoped custom event with additional caller-provided dimensions.
+ procedure LogCustomEvent(var EscapeRoomTask: Record "Escape Room Task"; EventId: Text; EventMessage: Text; ScorePoints: Integer; ExtraDimensions: Dictionary of [Text, Text])
+ var
+ CustomDimensions: Dictionary of [Text, Text];
+ begin
+ GetCustomDimensionsForTask(EscapeRoomTask, CustomDimensions);
+ AddScorePoints(CustomDimensions, ClampScorePoints(ScorePoints));
+ AddCustomEventDimensions(CustomDimensions, EventId);
+ MergeExtraDimensions(CustomDimensions, ExtraDimensions);
+
+ this.LogMessage(
+ 'EscapeRoomCustomEvent',
+ EventMessage,
+ CustomDimensions
+ );
+ end;
+
+ /// Room-scoped custom event with additional caller-provided dimensions.
+ procedure LogCustomEvent(var EscapeRoom: Record "Escape Room"; EventId: Text; EventMessage: Text; ScorePoints: Integer; ExtraDimensions: Dictionary of [Text, Text])
+ var
+ CustomDimensions: Dictionary of [Text, Text];
+ begin
+ GetCustomDimensionsForRoom(EscapeRoom, CustomDimensions);
+ AddScorePoints(CustomDimensions, ClampScorePoints(ScorePoints));
+ AddCustomEventDimensions(CustomDimensions, EventId);
+ MergeExtraDimensions(CustomDimensions, ExtraDimensions);
+
+ this.LogMessage(
+ 'EscapeRoomCustomEvent',
+ EventMessage,
+ CustomDimensions
+ );
+ end;
+
+ local procedure ClampScorePoints(ScorePoints: Integer): Integer
+ begin
+ if ScorePoints < -5 then
+ exit(-5);
+ if ScorePoints > 5 then
+ exit(5);
+ exit(ScorePoints);
+ end;
+
+ local procedure AddCustomEventDimensions(var CustomDimensions: Dictionary of [Text, Text]; EventId: Text)
+ begin
+ EventId := EventId.Trim();
+ if EventId = '' then
+ EventId := 'Unspecified';
+ if StrLen(EventId) > 80 then
+ EventId := CopyStr(EventId, 1, 80);
+
+ CustomDimensions.Add('EventId', EventId);
+ CustomDimensions.Add('EventSource', 'Custom');
+ end;
+
+ local procedure MergeExtraDimensions(var CustomDimensions: Dictionary of [Text, Text]; ExtraDimensions: Dictionary of [Text, Text])
+ var
+ DimensionKey: Text;
+ begin
+ foreach DimensionKey in ExtraDimensions.Keys() do
+ if not CustomDimensions.ContainsKey(DimensionKey) then
+ CustomDimensions.Add(DimensionKey, ExtraDimensions.Get(DimensionKey));
+ end;
+
local procedure GetCustomDimensionsForRoom(var Room: Record "Escape Room"; var CustomDimensions: Dictionary of [Text, Text])
var
Venue: Record "Escape Room Venue";
diff --git a/non production apps/EscapeRoomApp/app.json b/non production apps/EscapeRoomApp/app.json
index 73cf2916..1bc7963d 100644
--- a/non production apps/EscapeRoomApp/app.json
+++ b/non production apps/EscapeRoomApp/app.json
@@ -2,7 +2,7 @@
"id": "f03c0f0c-d887-4279-b226-dea59737ecf8",
"name": "BCTalent.EscapeRoom",
"publisher": "waldo & AJ",
- "version": "1.3.10026.4",
+ "version": "1.4.1.0",
"brief": "",
"description": "",
"privacyStatement": "",