@voicethere/agent 0.5.4 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11362,12 +11362,20 @@ function agentLog(level, message, fieldsOrSessionId, sessionId) {
11362
11362
  var MAX_LIVE_OBJECTS = 25;
11363
11363
  var REGISTER_NACK_REASON_WORLD_FULL = "world_full";
11364
11364
  var UNREGISTER_NACK_REASON_NOT_FOUND = "not_found";
11365
- var UNREGISTER_NACK_REASON_NOT_OWNER = "not_owner";
11366
11365
  function parseRegisterCommand(message) {
11367
11366
  if (!message || typeof message !== "object") return false;
11368
11367
  const record = message;
11369
11368
  return record.type === "register";
11370
11369
  }
11370
+ function resolveRemoveTarget(objectId, ownedObjectIds) {
11371
+ if (objectId !== void 0) {
11372
+ return { ok: true, objectId };
11373
+ }
11374
+ if (!ownedObjectIds || ownedObjectIds.size === 0) {
11375
+ return { ok: false, reason: UNREGISTER_NACK_REASON_NOT_FOUND };
11376
+ }
11377
+ return { ok: true, objectId: Math.max(...ownedObjectIds) };
11378
+ }
11371
11379
  function parseUnregisterCommand(message) {
11372
11380
  if (!message || typeof message !== "object") return null;
11373
11381
  const record = message;
@@ -11449,6 +11457,16 @@ function writeObjectSlot(world, slot, objectId, posX, posY, posZ, posW, dirX, di
11449
11457
  world[start + 7] = dirZ;
11450
11458
  world[start + 8] = dirW;
11451
11459
  }
11460
+ function preserveEmptySlots(simulated, authoritative) {
11461
+ for (let slot = 0; slot < MAX_LIVE_OBJECTS; slot += 1) {
11462
+ if (readSlotObjectId(authoritative, slot) === 0) {
11463
+ markSlotFree(simulated, slot);
11464
+ }
11465
+ }
11466
+ }
11467
+ function commitSimulatedWorld(simulated, latestRedis) {
11468
+ preserveEmptySlots(simulated, latestRedis);
11469
+ }
11452
11470
  function collectActiveObjectIds(world) {
11453
11471
  const ids = [];
11454
11472
  for (let slot = 0; slot < MAX_LIVE_OBJECTS; slot += 1) {
@@ -11686,6 +11704,7 @@ function simulateWorldStep(worldState2, dtSec, activeObjectIds) {
11686
11704
  var BROADCAST_HZ = 60;
11687
11705
  var BROADCAST_INTERVAL_MS = Math.floor(1e3 / BROADCAST_HZ);
11688
11706
  var SIM_LOCK_TTL_MS = BROADCAST_INTERVAL_MS * 2;
11707
+ var SIM_LOCK_RETRY_MS = 5;
11689
11708
  var MIN_SPEED = 90;
11690
11709
  var MAX_SPEED = 180;
11691
11710
  var connectedSessions = /* @__PURE__ */ new Set();
@@ -11695,6 +11714,46 @@ var freeSlots = [];
11695
11714
  var worldState = createEmptyWorldBuffer();
11696
11715
  var redis = null;
11697
11716
  var broadcastTimer = null;
11717
+ var worldMutationChain = Promise.resolve();
11718
+ function withWorldMutation(fn) {
11719
+ const run = worldMutationChain.then(fn);
11720
+ worldMutationChain = run.then(
11721
+ () => void 0,
11722
+ () => void 0
11723
+ );
11724
+ return run;
11725
+ }
11726
+ function sleep(ms) {
11727
+ return new Promise((resolve) => {
11728
+ setTimeout(resolve, ms);
11729
+ });
11730
+ }
11731
+ async function withRedisSimLock(fn, options) {
11732
+ if (!redis) {
11733
+ return fn();
11734
+ }
11735
+ const retryUntilAcquired = options?.retryUntilAcquired ?? true;
11736
+ while (true) {
11737
+ const lockAcquired = await redis.set(
11738
+ REDIS_SIM_LOCK_KEY,
11739
+ "1",
11740
+ "PX",
11741
+ SIM_LOCK_TTL_MS,
11742
+ "NX"
11743
+ );
11744
+ if (lockAcquired === "OK") {
11745
+ try {
11746
+ return await fn();
11747
+ } finally {
11748
+ await redis.del(REDIS_SIM_LOCK_KEY);
11749
+ }
11750
+ }
11751
+ if (!retryUntilAcquired) {
11752
+ return null;
11753
+ }
11754
+ await sleep(SIM_LOCK_RETRY_MS);
11755
+ }
11756
+ }
11698
11757
  function rand(min, max) {
11699
11758
  return min + Math.random() * (max - min);
11700
11759
  }
@@ -11794,6 +11853,7 @@ async function registerObjectInRedis(sessionId) {
11794
11853
  return null;
11795
11854
  }
11796
11855
  attachObjectToSession(sessionId, objectId);
11856
+ worldState = await loadWorldFromRedis();
11797
11857
  return objectId;
11798
11858
  }
11799
11859
  async function releaseObjectInRedis(objectId) {
@@ -11812,35 +11872,32 @@ async function releaseObjectInRedis(objectId) {
11812
11872
  String(objectId),
11813
11873
  REDIS_EVAL_KEYS.headers
11814
11874
  );
11875
+ if (Number(released) === 1) {
11876
+ worldState = await loadWorldFromRedis();
11877
+ }
11815
11878
  return Number(released) === 1;
11816
11879
  }
11817
- function highestOwnedObjectId(sessionId) {
11818
- const owned = sessionObjects.get(sessionId);
11819
- if (!owned || owned.size === 0) return null;
11820
- return Math.max(...owned);
11821
- }
11822
11880
  async function unregisterObject(sessionId, objectId) {
11823
11881
  const owned = sessionObjects.get(sessionId);
11824
- if (!owned || owned.size === 0) {
11825
- return { ok: false, reason: UNREGISTER_NACK_REASON_NOT_FOUND };
11882
+ const target = resolveRemoveTarget(objectId, owned);
11883
+ if (!target.ok) {
11884
+ return target;
11826
11885
  }
11827
- let targetId = objectId;
11828
- if (targetId === void 0) {
11829
- const highest = highestOwnedObjectId(sessionId);
11830
- if (highest === null) {
11831
- return { ok: false, reason: UNREGISTER_NACK_REASON_NOT_FOUND };
11832
- }
11833
- targetId = highest;
11834
- }
11835
- if (!owned.has(targetId)) {
11836
- if (objectId !== void 0) {
11837
- return { ok: false, reason: UNREGISTER_NACK_REASON_NOT_OWNER };
11838
- }
11886
+ const previousOwner = objectOwners.get(target.objectId) ?? sessionId;
11887
+ notifyObjectReleased(target.objectId, previousOwner);
11888
+ const released = await withWorldMutation(async () => {
11889
+ if (!redis) {
11890
+ return releaseObjectInRedis(target.objectId);
11891
+ }
11892
+ const result = await withRedisSimLock(
11893
+ () => releaseObjectInRedis(target.objectId)
11894
+ );
11895
+ return result === true;
11896
+ });
11897
+ if (!released) {
11839
11898
  return { ok: false, reason: UNREGISTER_NACK_REASON_NOT_FOUND };
11840
11899
  }
11841
- notifyObjectReleased(targetId, sessionId);
11842
- await releaseObjectInRedis(targetId);
11843
- return { ok: true, objectId: targetId };
11900
+ return { ok: true, objectId: target.objectId };
11844
11901
  }
11845
11902
  function trackedObjectsSnapshot() {
11846
11903
  const objects = [];
@@ -11893,34 +11950,36 @@ async function saveWorldToRedis(world) {
11893
11950
  }
11894
11951
  async function runSimulationTick() {
11895
11952
  if (!redis) {
11896
- const activeObjectIds = [...objectOwners.keys()];
11897
- simulateWorldStep(worldState, 1 / BROADCAST_HZ, activeObjectIds);
11953
+ await withWorldMutation(async () => {
11954
+ const activeObjectIds = [...objectOwners.keys()];
11955
+ simulateWorldStep(worldState, 1 / BROADCAST_HZ, activeObjectIds);
11956
+ });
11898
11957
  broadcastWorldBuffer(worldState);
11899
11958
  return;
11900
11959
  }
11901
- const lockAcquired = await redis.set(
11902
- REDIS_SIM_LOCK_KEY,
11903
- "1",
11904
- "PX",
11905
- SIM_LOCK_TTL_MS,
11906
- "NX"
11907
- );
11908
- if (lockAcquired === "OK") {
11909
- const world2 = await loadWorldFromRedis();
11910
- const activeObjectIds = collectActiveObjectIds(world2);
11911
- simulateWorldStep(world2, 1 / BROADCAST_HZ, activeObjectIds);
11912
- await saveWorldToRedis(world2);
11913
- worldState = world2;
11914
- }
11960
+ await withWorldMutation(async () => {
11961
+ await withRedisSimLock(
11962
+ async () => {
11963
+ const world2 = await loadWorldFromRedis();
11964
+ const activeObjectIds = collectActiveObjectIds(world2);
11965
+ simulateWorldStep(world2, 1 / BROADCAST_HZ, activeObjectIds);
11966
+ const latestRedis = await loadWorldFromRedis();
11967
+ commitSimulatedWorld(world2, latestRedis);
11968
+ await saveWorldToRedis(world2);
11969
+ worldState = world2;
11970
+ },
11971
+ { retryUntilAcquired: false }
11972
+ );
11973
+ });
11915
11974
  const world = await loadWorldFromRedis();
11916
11975
  worldState = world;
11917
11976
  broadcastWorldBuffer(world);
11918
11977
  }
11919
11978
  function startBroadcastLoopIfNeeded() {
11920
11979
  if (broadcastTimer) return;
11921
- if (connectedSessions.size < 1) return;
11980
+ if (!redis && connectedSessions.size < 1) return;
11922
11981
  broadcastTimer = setInterval(() => {
11923
- if (connectedSessions.size < 1) {
11982
+ if (!redis && connectedSessions.size < 1) {
11924
11983
  if (broadcastTimer) {
11925
11984
  clearInterval(broadcastTimer);
11926
11985
  broadcastTimer = null;
@@ -11935,6 +11994,7 @@ function startBroadcastLoopIfNeeded() {
11935
11994
  agentLog("info", `world loop started (${BROADCAST_HZ}Hz)`);
11936
11995
  }
11937
11996
  function stopBroadcastLoopIfNeeded() {
11997
+ if (redis) return;
11938
11998
  if (connectedSessions.size >= 1) return;
11939
11999
  if (!broadcastTimer) return;
11940
12000
  clearInterval(broadcastTimer);
@@ -11965,8 +12025,9 @@ defineAgent({
11965
12025
  lazyConnect: true
11966
12026
  });
11967
12027
  await redis.connect();
11968
- worldState = createEmptyWorldBuffer();
11969
12028
  await ensureRedisWorldInitialized();
12029
+ worldState = await loadWorldFromRedis();
12030
+ startBroadcastLoopIfNeeded();
11970
12031
  agentLog("info", "game-sync agent connected to project Redis world buffer");
11971
12032
  },
11972
12033
  onClientJoin({ sessionId }) {
@@ -11980,14 +12041,12 @@ defineAgent({
11980
12041
  },
11981
12042
  async onClientLeave({ sessionId }) {
11982
12043
  connectedSessions.delete(sessionId);
11983
- const owned = sessionObjects.get(sessionId);
11984
- if (owned) {
11985
- for (const objectId of [...owned]) {
11986
- notifyObjectReleased(objectId, sessionId);
11987
- await releaseObjectInRedis(objectId);
12044
+ for (const [objectId, ownerSessionId] of objectOwners) {
12045
+ if (ownerSessionId === sessionId) {
12046
+ objectOwners.delete(objectId);
11988
12047
  }
11989
- sessionObjects.delete(sessionId);
11990
12048
  }
12049
+ sessionObjects.delete(sessionId);
11991
12050
  stopBroadcastLoopIfNeeded();
11992
12051
  agentLog(
11993
12052
  "info",
@@ -11996,7 +12055,15 @@ defineAgent({
11996
12055
  },
11997
12056
  async onDataChannelMessage(ctx) {
11998
12057
  if (parseRegisterCommand(ctx.message)) {
11999
- const objectId = redis ? await registerObjectInRedis(ctx.sessionId) : registerObjectInMemory(ctx.sessionId);
12058
+ const objectId = await withWorldMutation(async () => {
12059
+ if (!redis) {
12060
+ return registerObjectInMemory(ctx.sessionId);
12061
+ }
12062
+ const result = await withRedisSimLock(
12063
+ () => registerObjectInRedis(ctx.sessionId)
12064
+ );
12065
+ return result;
12066
+ });
12000
12067
  if (objectId === null) {
12001
12068
  sendToClient(ctx.sessionId, {
12002
12069
  type: "register_nack",
@@ -669,6 +669,136 @@ function pickFunFact() {
669
669
  var GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search";
670
670
  var FORECAST_URL = "https://api.open-meteo.com/v1/forecast";
671
671
  var FETCH_TIMEOUT_MS = 8e3;
672
+ var DIGIT_WORDS = {
673
+ zero: "0",
674
+ oh: "0",
675
+ o: "0",
676
+ one: "1",
677
+ two: "2",
678
+ three: "3",
679
+ four: "4",
680
+ five: "5",
681
+ six: "6",
682
+ seven: "7",
683
+ eight: "8",
684
+ nine: "9"
685
+ };
686
+ var COUNTRY_ALIASES = {
687
+ thailand: "Thailand",
688
+ us: "United States",
689
+ usa: "United States",
690
+ america: "United States",
691
+ "united states": "United States",
692
+ "united states of america": "United States",
693
+ uk: "United Kingdom",
694
+ britain: "United Kingdom",
695
+ england: "United Kingdom",
696
+ "united kingdom": "United Kingdom",
697
+ "great britain": "United Kingdom",
698
+ germany: "Germany",
699
+ france: "France",
700
+ spain: "Spain",
701
+ italy: "Italy",
702
+ japan: "Japan",
703
+ china: "China",
704
+ india: "India",
705
+ australia: "Australia",
706
+ canada: "Canada",
707
+ brazil: "Brazil",
708
+ mexico: "Mexico",
709
+ netherlands: "Netherlands",
710
+ holland: "Netherlands",
711
+ "the netherlands": "Netherlands",
712
+ belgium: "Belgium",
713
+ switzerland: "Switzerland",
714
+ sweden: "Sweden",
715
+ norway: "Norway",
716
+ denmark: "Denmark",
717
+ finland: "Finland",
718
+ poland: "Poland",
719
+ portugal: "Portugal",
720
+ greece: "Greece",
721
+ turkey: "Turkey",
722
+ egypt: "Egypt",
723
+ "south africa": "South Africa",
724
+ "new zealand": "New Zealand",
725
+ ireland: "Ireland",
726
+ singapore: "Singapore",
727
+ malaysia: "Malaysia",
728
+ indonesia: "Indonesia",
729
+ vietnam: "Vietnam",
730
+ philippines: "Philippines",
731
+ "south korea": "South Korea",
732
+ korea: "South Korea",
733
+ taiwan: "Taiwan",
734
+ "hong kong": "Hong Kong",
735
+ israel: "Israel",
736
+ uae: "United Arab Emirates",
737
+ "united arab emirates": "United Arab Emirates",
738
+ "saudi arabia": "Saudi Arabia",
739
+ pakistan: "Pakistan",
740
+ bangladesh: "Bangladesh",
741
+ nigeria: "Nigeria",
742
+ kenya: "Kenya",
743
+ argentina: "Argentina",
744
+ chile: "Chile",
745
+ colombia: "Colombia",
746
+ peru: "Peru",
747
+ austria: "Austria",
748
+ "czech republic": "Czechia",
749
+ czechia: "Czechia",
750
+ romania: "Romania",
751
+ hungary: "Hungary",
752
+ ukraine: "Ukraine"
753
+ };
754
+ function matchCountryName(text) {
755
+ const key = text.trim().toLowerCase().replace(/[.,!?]+$/g, "").replace(/\s+/g, " ");
756
+ if (!key) return null;
757
+ return COUNTRY_ALIASES[key] ?? null;
758
+ }
759
+ function spokenDigitsToPostal(text) {
760
+ const tokens = text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
761
+ const digits = [];
762
+ for (const token of tokens) {
763
+ if (/^\d$/.test(token)) {
764
+ digits.push(token);
765
+ continue;
766
+ }
767
+ const mapped = DIGIT_WORDS[token];
768
+ if (mapped) {
769
+ digits.push(mapped);
770
+ }
771
+ }
772
+ if (digits.length >= 4 && digits.length <= 6) {
773
+ return digits.join("");
774
+ }
775
+ return null;
776
+ }
777
+ function splitTrailingCountry(text) {
778
+ const words = text.trim().split(/\s+/).filter(Boolean);
779
+ if (words.length < 2) return null;
780
+ for (let n = Math.min(3, words.length - 1); n >= 1; n -= 1) {
781
+ const tail = words.slice(-n).join(" ");
782
+ const country = matchCountryName(tail);
783
+ if (country) {
784
+ return { rest: words.slice(0, -n).join(" "), country };
785
+ }
786
+ }
787
+ return null;
788
+ }
789
+ function cityFromRemainder(rest) {
790
+ const trimmed = rest.trim().replace(/[.,!?;:]+$/g, "").replace(/\s+(?:in the|in|of)$/i, "").trim();
791
+ if (!trimmed) return null;
792
+ if (/^\d{4,6}(-\d{4})?$/.test(trimmed)) {
793
+ return trimmed;
794
+ }
795
+ const spoken = spokenDigitsToPostal(trimmed);
796
+ if (spoken) return spoken;
797
+ if (trimmed.length >= 2 && trimmed.length <= 60) {
798
+ return trimmed;
799
+ }
800
+ return null;
801
+ }
672
802
  function wmoCodeToPhrase(code) {
673
803
  if (code === 0) return "clear sky";
674
804
  if (code <= 3) return "partly cloudy";
@@ -689,29 +819,48 @@ function formatWeatherSpeech(result) {
689
819
  function parseLocationUtterance(utterance) {
690
820
  const text = utterance.trim();
691
821
  if (!text) return null;
822
+ const countryOnly = matchCountryName(text);
823
+ if (countryOnly) {
824
+ return { country: countryOnly };
825
+ }
826
+ const trailing = splitTrailingCountry(text);
827
+ if (trailing) {
828
+ const city = cityFromRemainder(trailing.rest);
829
+ if (city) {
830
+ return { city, country: trailing.country };
831
+ }
832
+ return { country: trailing.country };
833
+ }
692
834
  const inMatch = text.match(
693
835
  /^(?:in\s+)?(.+?)\s+in\s+([a-zA-Z][\w\s.-]{1,40})$/i
694
836
  );
695
837
  if (inMatch) {
696
- return { city: inMatch[1].trim(), country: inMatch[2].trim() };
838
+ const country = matchCountryName(inMatch[2]) ?? inMatch[2].trim();
839
+ return { city: inMatch[1].trim(), country };
697
840
  }
698
841
  const commaMatch = text.match(/^(.+?),\s*([a-zA-Z][\w\s.-]{1,40})$/);
699
842
  if (commaMatch) {
700
- return { city: commaMatch[1].trim(), country: commaMatch[2].trim() };
843
+ const country = matchCountryName(commaMatch[2]) ?? commaMatch[2].trim();
844
+ return { city: commaMatch[1].trim(), country };
701
845
  }
702
846
  const countryMatch = text.match(
703
847
  /^(.+?)\s+(?:country\s+)?([a-zA-Z][\w\s.-]{2,40})$/i
704
848
  );
705
849
  if (countryMatch && countryMatch[2].split(/\s+/).length <= 3) {
706
850
  const city = countryMatch[1].trim();
707
- const country = countryMatch[2].trim();
851
+ const countryRaw = countryMatch[2].trim();
852
+ const country = matchCountryName(countryRaw) ?? countryRaw;
708
853
  if (city.length >= 2 && country.length >= 2) {
709
854
  return { city, country };
710
855
  }
711
856
  }
712
- if (/^\d{5}(-\d{4})?$/.test(text)) {
857
+ if (/^\d{4,6}(-\d{4})?$/.test(text)) {
713
858
  return { city: text };
714
859
  }
860
+ const spokenPostal = spokenDigitsToPostal(text);
861
+ if (spokenPostal) {
862
+ return { city: spokenPostal };
863
+ }
715
864
  if (text.length >= 2 && text.length <= 60) {
716
865
  return { city: text };
717
866
  }
@@ -1059,8 +1208,15 @@ function handleUtterance(state, utterance) {
1059
1208
  }
1060
1209
  case "weatherAwaitingLocation": {
1061
1210
  const parsed = parseLocationUtterance(text);
1062
- const city = parsed?.city ?? state.weatherCity;
1063
- const country = parsed?.country ?? state.weatherCountry;
1211
+ let city = parsed?.city || state.weatherCity;
1212
+ let country = parsed?.country || state.weatherCountry;
1213
+ if (state.weatherCity && !country) {
1214
+ const followUp = matchCountryName(text) ?? (parsed?.city ? matchCountryName(parsed.city) : null);
1215
+ if (followUp) {
1216
+ city = state.weatherCity;
1217
+ country = followUp;
1218
+ }
1219
+ }
1064
1220
  if (!city) {
1065
1221
  const ask = speakAndChat(
1066
1222
  "Please tell me a city or ZIP code and the country."
@@ -1071,7 +1227,7 @@ function handleUtterance(state, utterance) {
1071
1227
  messages: ask.messages
1072
1228
  };
1073
1229
  }
1074
- if (!country && !parsed?.country && !state.weatherCountry) {
1230
+ if (!country) {
1075
1231
  return {
1076
1232
  state: {
1077
1233
  ...state,
@@ -1084,12 +1240,11 @@ function handleUtterance(state, utterance) {
1084
1240
  ]
1085
1241
  };
1086
1242
  }
1087
- const resolvedCountry = country ?? state.weatherCountry;
1088
1243
  return {
1089
- state: { ...state, weatherCity: city, weatherCountry: resolvedCountry },
1244
+ state: { ...state, weatherCity: city, weatherCountry: country },
1090
1245
  speakLines: [],
1091
1246
  messages: [],
1092
- pendingWeather: { city, country: resolvedCountry }
1247
+ pendingWeather: { city, country }
1093
1248
  };
1094
1249
  }
1095
1250
  case "countAwaitingNumber": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voicethere/agent",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "VoiceThere customer agent SDK — IPC types and runtime helpers for sandboxed child bundles",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,6 +19,19 @@ export interface UnregisterCommand {
19
19
  objectId?: number;
20
20
  }
21
21
 
22
+ export function resolveRemoveTarget(
23
+ objectId: number | undefined,
24
+ ownedObjectIds: ReadonlySet<number> | undefined | null,
25
+ ): { ok: true; objectId: number } | { ok: false; reason: string } {
26
+ if (objectId !== undefined) {
27
+ return { ok: true, objectId };
28
+ }
29
+ if (!ownedObjectIds || ownedObjectIds.size === 0) {
30
+ return { ok: false, reason: UNREGISTER_NACK_REASON_NOT_FOUND };
31
+ }
32
+ return { ok: true, objectId: Math.max(...ownedObjectIds) };
33
+ }
34
+
22
35
  export function parseUnregisterCommand(
23
36
  message: unknown,
24
37
  ): UnregisterCommand | null {
@@ -124,6 +124,33 @@ export function writeObjectSlot(
124
124
  world[start + 8] = dirW;
125
125
  }
126
126
 
127
+ /**
128
+ * After simulating from a Redis load, keep slots empty that a concurrent release
129
+ * zeroed in Redis while the tick was in flight — prevents saveWorldToRedis from
130
+ * restoring an object Lua just cleared (sim GET → simulate → SET race).
131
+ *
132
+ * `authoritative` must be a fresh Redis GET after simulate (never stale in-memory
133
+ * worldState — an empty buffer would wipe every live slot).
134
+ */
135
+ export function preserveEmptySlots(
136
+ simulated: Float32Array,
137
+ authoritative: Float32Array,
138
+ ): void {
139
+ for (let slot = 0; slot < MAX_LIVE_OBJECTS; slot += 1) {
140
+ if (readSlotObjectId(authoritative, slot) === 0) {
141
+ markSlotFree(simulated, slot);
142
+ }
143
+ }
144
+ }
145
+
146
+ /** Merge a simulated world with the latest Redis snapshot before SET. */
147
+ export function commitSimulatedWorld(
148
+ simulated: Float32Array,
149
+ latestRedis: Float32Array,
150
+ ): void {
151
+ preserveEmptySlots(simulated, latestRedis);
152
+ }
153
+
127
154
  export function collectActiveObjectIds(world: Float32Array): number[] {
128
155
  const ids: number[] = [];
129
156
  for (let slot = 0; slot < MAX_LIVE_OBJECTS; slot += 1) {
@@ -7,24 +7,27 @@
7
7
  * [objectId, posX, posY, posZ, posW, dirX, dirY, dirZ, dirW]
8
8
  *
9
9
  * With project Redis (`AGENT_REDIS_URL`), the world blob is shared across runner
10
- * workers (key `game-sync:world`). One worker holds a sim lock per tick, runs
11
- * physics, and writes the blob; every worker GET+broadcasts to local sessions.
10
+ * workers (key `game-sync:world`). World writes (allocate/release Lua, sim SET)
11
+ * serialize on `game-sync:sim-lock`; one holder runs physics per tick.
12
12
  *
13
13
  * Control messages:
14
14
  * - `{ type: "register" }` -> allocates (or reuses) one 9-float slot
15
15
  * - server replies `{ type: "register_ack", objectId }`
16
16
  * - `{ type: "register_nack", reason: "world_full", maxObjects: 25 }` when cap reached
17
- * - `{ type: "unregister" }` or `{ type: "remove", objectId?: number }` -> releases owned object(s)
17
+ * - `{ type: "remove", objectId }` or `{ type: "unregister", objectId?: number }` ->
18
+ * zeros that live slot (objectId required for click-to-remove; omit objectId on
19
+ * unregister to drop the highest object owned by this session)
18
20
  * - `{ type: "unregister_ack", objectId }` or `{ type: "unregister_nack", reason }`
19
21
  *
20
22
  * Simulation:
21
23
  * - server-authoritative movement at 60Hz
22
24
  * - wall bounce + object-object elastic collisions on server
23
25
  * - clients render server snapshots; client binary writes are ignored
26
+ * - with project Redis, the sim loop keeps running with zero connected clients so
27
+ * objects persist and keep moving after everyone disconnects
24
28
  *
25
29
  * Broadcast:
26
- * - 60Hz world-state broadcast starts when at least 1 client is connected
27
- * - stops when connected client count drops below 1
30
+ * - 60Hz world-state broadcast while the sim loop runs (no-op send when 0 sessions)
28
31
  *
29
32
  * Build:
30
33
  * npx @voicethere/agent build --entry templates/game-sync.ts
@@ -42,9 +45,9 @@ import {
42
45
  parseChatCommand,
43
46
  parseRegisterCommand,
44
47
  parseUnregisterCommand,
48
+ resolveRemoveTarget,
45
49
  REGISTER_NACK_REASON_WORLD_FULL,
46
50
  UNREGISTER_NACK_REASON_NOT_FOUND,
47
- UNREGISTER_NACK_REASON_NOT_OWNER,
48
51
  } from "./game-sync-protocol.js";
49
52
  import {
50
53
  LUA_ALLOCATE_OBJECT,
@@ -59,6 +62,7 @@ import {
59
62
  } from "./game-sync-sim.js";
60
63
  import {
61
64
  collectActiveObjectIds,
65
+ commitSimulatedWorld,
62
66
  countLiveObjects,
63
67
  createEmptyWorldBuffer,
64
68
  findFirstEmptySlot,
@@ -75,6 +79,7 @@ import {
75
79
  const BROADCAST_HZ = 60;
76
80
  const BROADCAST_INTERVAL_MS = Math.floor(1000 / BROADCAST_HZ);
77
81
  const SIM_LOCK_TTL_MS = BROADCAST_INTERVAL_MS * 2;
82
+ const SIM_LOCK_RETRY_MS = 5;
78
83
  const MIN_SPEED = 90;
79
84
  const MAX_SPEED = 180;
80
85
 
@@ -86,6 +91,54 @@ const freeSlots: number[] = [];
86
91
  let worldState = createEmptyWorldBuffer();
87
92
  let redis: Redis | null = null;
88
93
  let broadcastTimer: NodeJS.Timeout | null = null;
94
+ let worldMutationChain: Promise<void> = Promise.resolve();
95
+
96
+ function withWorldMutation<T>(fn: () => Promise<T>): Promise<T> {
97
+ const run = worldMutationChain.then(fn);
98
+ worldMutationChain = run.then(
99
+ () => undefined,
100
+ () => undefined,
101
+ );
102
+ return run;
103
+ }
104
+
105
+ function sleep(ms: number): Promise<void> {
106
+ return new Promise((resolve) => {
107
+ setTimeout(resolve, ms);
108
+ });
109
+ }
110
+
111
+ async function withRedisSimLock<T>(
112
+ fn: () => Promise<T>,
113
+ options?: { retryUntilAcquired?: boolean },
114
+ ): Promise<T | null> {
115
+ if (!redis) {
116
+ return fn();
117
+ }
118
+
119
+ const retryUntilAcquired = options?.retryUntilAcquired ?? true;
120
+
121
+ while (true) {
122
+ const lockAcquired = await redis.set(
123
+ REDIS_SIM_LOCK_KEY,
124
+ "1",
125
+ "PX",
126
+ SIM_LOCK_TTL_MS,
127
+ "NX",
128
+ );
129
+ if (lockAcquired === "OK") {
130
+ try {
131
+ return await fn();
132
+ } finally {
133
+ await redis.del(REDIS_SIM_LOCK_KEY);
134
+ }
135
+ }
136
+ if (!retryUntilAcquired) {
137
+ return null;
138
+ }
139
+ await sleep(SIM_LOCK_RETRY_MS);
140
+ }
141
+ }
89
142
 
90
143
  interface TrackedObjectInfo {
91
144
  objectId: number;
@@ -208,6 +261,7 @@ async function registerObjectInRedis(
208
261
  }
209
262
 
210
263
  attachObjectToSession(sessionId, objectId);
264
+ worldState = await loadWorldFromRedis();
211
265
  return objectId;
212
266
  }
213
267
 
@@ -228,43 +282,37 @@ async function releaseObjectInRedis(objectId: number): Promise<boolean> {
228
282
  String(objectId),
229
283
  REDIS_EVAL_KEYS.headers,
230
284
  );
285
+ if (Number(released) === 1) {
286
+ worldState = await loadWorldFromRedis();
287
+ }
231
288
  return Number(released) === 1;
232
289
  }
233
290
 
234
- function highestOwnedObjectId(sessionId: string): number | null {
235
- const owned = sessionObjects.get(sessionId);
236
- if (!owned || owned.size === 0) return null;
237
- return Math.max(...owned);
238
- }
239
-
240
291
  async function unregisterObject(
241
292
  sessionId: string,
242
293
  objectId?: number,
243
294
  ): Promise<{ ok: true; objectId: number } | { ok: false; reason: string }> {
244
295
  const owned = sessionObjects.get(sessionId);
245
- if (!owned || owned.size === 0) {
246
- return { ok: false, reason: UNREGISTER_NACK_REASON_NOT_FOUND };
296
+ const target = resolveRemoveTarget(objectId, owned);
297
+ if (!target.ok) {
298
+ return target;
247
299
  }
248
300
 
249
- let targetId = objectId;
250
- if (targetId === undefined) {
251
- const highest = highestOwnedObjectId(sessionId);
252
- if (highest === null) {
253
- return { ok: false, reason: UNREGISTER_NACK_REASON_NOT_FOUND };
254
- }
255
- targetId = highest;
256
- }
257
-
258
- if (!owned.has(targetId)) {
259
- if (objectId !== undefined) {
260
- return { ok: false, reason: UNREGISTER_NACK_REASON_NOT_OWNER };
301
+ const previousOwner = objectOwners.get(target.objectId) ?? sessionId;
302
+ notifyObjectReleased(target.objectId, previousOwner);
303
+ const released = await withWorldMutation(async () => {
304
+ if (!redis) {
305
+ return releaseObjectInRedis(target.objectId);
261
306
  }
307
+ const result = await withRedisSimLock(() =>
308
+ releaseObjectInRedis(target.objectId),
309
+ );
310
+ return result === true;
311
+ });
312
+ if (!released) {
262
313
  return { ok: false, reason: UNREGISTER_NACK_REASON_NOT_FOUND };
263
314
  }
264
-
265
- notifyObjectReleased(targetId, sessionId);
266
- await releaseObjectInRedis(targetId);
267
- return { ok: true, objectId: targetId };
315
+ return { ok: true, objectId: target.objectId };
268
316
  }
269
317
 
270
318
  function trackedObjectsSnapshot(): TrackedObjectInfo[] {
@@ -328,26 +376,28 @@ async function saveWorldToRedis(world: Float32Array): Promise<void> {
328
376
 
329
377
  async function runSimulationTick(): Promise<void> {
330
378
  if (!redis) {
331
- const activeObjectIds = [...objectOwners.keys()];
332
- simulateWorldStep(worldState, 1 / BROADCAST_HZ, activeObjectIds);
379
+ await withWorldMutation(async () => {
380
+ const activeObjectIds = [...objectOwners.keys()];
381
+ simulateWorldStep(worldState, 1 / BROADCAST_HZ, activeObjectIds);
382
+ });
333
383
  broadcastWorldBuffer(worldState);
334
384
  return;
335
385
  }
336
386
 
337
- const lockAcquired = await redis.set(
338
- REDIS_SIM_LOCK_KEY,
339
- "1",
340
- "PX",
341
- SIM_LOCK_TTL_MS,
342
- "NX",
343
- );
344
- if (lockAcquired === "OK") {
345
- const world = await loadWorldFromRedis();
346
- const activeObjectIds = collectActiveObjectIds(world);
347
- simulateWorldStep(world, 1 / BROADCAST_HZ, activeObjectIds);
348
- await saveWorldToRedis(world);
349
- worldState = world;
350
- }
387
+ await withWorldMutation(async () => {
388
+ await withRedisSimLock(
389
+ async () => {
390
+ const world = await loadWorldFromRedis();
391
+ const activeObjectIds = collectActiveObjectIds(world);
392
+ simulateWorldStep(world, 1 / BROADCAST_HZ, activeObjectIds);
393
+ const latestRedis = await loadWorldFromRedis();
394
+ commitSimulatedWorld(world, latestRedis);
395
+ await saveWorldToRedis(world);
396
+ worldState = world;
397
+ },
398
+ { retryUntilAcquired: false },
399
+ );
400
+ });
351
401
 
352
402
  const world = await loadWorldFromRedis();
353
403
  worldState = world;
@@ -356,10 +406,10 @@ async function runSimulationTick(): Promise<void> {
356
406
 
357
407
  function startBroadcastLoopIfNeeded(): void {
358
408
  if (broadcastTimer) return;
359
- if (connectedSessions.size < 1) return;
409
+ if (!redis && connectedSessions.size < 1) return;
360
410
 
361
411
  broadcastTimer = setInterval(() => {
362
- if (connectedSessions.size < 1) {
412
+ if (!redis && connectedSessions.size < 1) {
363
413
  if (broadcastTimer) {
364
414
  clearInterval(broadcastTimer);
365
415
  broadcastTimer = null;
@@ -376,6 +426,7 @@ function startBroadcastLoopIfNeeded(): void {
376
426
  }
377
427
 
378
428
  function stopBroadcastLoopIfNeeded(): void {
429
+ if (redis) return;
379
430
  if (connectedSessions.size >= 1) return;
380
431
  if (!broadcastTimer) return;
381
432
  clearInterval(broadcastTimer);
@@ -409,8 +460,9 @@ defineAgent({
409
460
  lazyConnect: true,
410
461
  });
411
462
  await redis.connect();
412
- worldState = createEmptyWorldBuffer();
413
463
  await ensureRedisWorldInitialized();
464
+ worldState = await loadWorldFromRedis();
465
+ startBroadcastLoopIfNeeded();
414
466
  agentLog("info", "game-sync agent connected to project Redis world buffer");
415
467
  },
416
468
 
@@ -426,16 +478,12 @@ defineAgent({
426
478
 
427
479
  async onClientLeave({ sessionId }) {
428
480
  connectedSessions.delete(sessionId);
429
-
430
- const owned = sessionObjects.get(sessionId);
431
- if (owned) {
432
- for (const objectId of [...owned]) {
433
- notifyObjectReleased(objectId, sessionId);
434
- await releaseObjectInRedis(objectId);
481
+ for (const [objectId, ownerSessionId] of objectOwners) {
482
+ if (ownerSessionId === sessionId) {
483
+ objectOwners.delete(objectId);
435
484
  }
436
- sessionObjects.delete(sessionId);
437
485
  }
438
-
486
+ sessionObjects.delete(sessionId);
439
487
  stopBroadcastLoopIfNeeded();
440
488
  agentLog(
441
489
  "info",
@@ -445,9 +493,15 @@ defineAgent({
445
493
 
446
494
  async onDataChannelMessage(ctx) {
447
495
  if (parseRegisterCommand(ctx.message)) {
448
- const objectId = redis
449
- ? await registerObjectInRedis(ctx.sessionId)
450
- : registerObjectInMemory(ctx.sessionId);
496
+ const objectId = await withWorldMutation(async () => {
497
+ if (!redis) {
498
+ return registerObjectInMemory(ctx.sessionId);
499
+ }
500
+ const result = await withRedisSimLock(() =>
501
+ registerObjectInRedis(ctx.sessionId),
502
+ );
503
+ return result;
504
+ });
451
505
  if (objectId === null) {
452
506
  sendToClient(ctx.sessionId, {
453
507
  type: "register_nack",
@@ -8,6 +8,7 @@ import { pickFunFact } from "./fun-facts.js";
8
8
  import {
9
9
  formatWeatherSpeech,
10
10
  lookupWeather,
11
+ matchCountryName,
11
12
  parseLocationUtterance,
12
13
  type FetchFn,
13
14
  type WeatherResult,
@@ -400,8 +401,19 @@ export function handleUtterance(
400
401
 
401
402
  case "weatherAwaitingLocation": {
402
403
  const parsed = parseLocationUtterance(text);
403
- const city = parsed?.city ?? state.weatherCity;
404
- const country = parsed?.country ?? state.weatherCountry;
404
+ let city = parsed?.city || state.weatherCity;
405
+ let country = parsed?.country || state.weatherCountry;
406
+
407
+ // Country-only follow-up: "Thailand" must not overwrite a stored ZIP as city.
408
+ if (state.weatherCity && !country) {
409
+ const followUp =
410
+ matchCountryName(text) ??
411
+ (parsed?.city ? matchCountryName(parsed.city) : null);
412
+ if (followUp) {
413
+ city = state.weatherCity;
414
+ country = followUp;
415
+ }
416
+ }
405
417
 
406
418
  if (!city) {
407
419
  const ask = speakAndChat(
@@ -414,7 +426,7 @@ export function handleUtterance(
414
426
  };
415
427
  }
416
428
 
417
- if (!country && !parsed?.country && !state.weatherCountry) {
429
+ if (!country) {
418
430
  return {
419
431
  state: {
420
432
  ...state,
@@ -428,12 +440,11 @@ export function handleUtterance(
428
440
  };
429
441
  }
430
442
 
431
- const resolvedCountry = country ?? state.weatherCountry;
432
443
  return {
433
- state: { ...state, weatherCity: city, weatherCountry: resolvedCountry },
444
+ state: { ...state, weatherCity: city, weatherCountry: country },
434
445
  speakLines: [],
435
446
  messages: [],
436
- pendingWeather: { city, country: resolvedCountry },
447
+ pendingWeather: { city, country },
437
448
  };
438
449
  }
439
450
 
@@ -20,6 +20,163 @@ export interface WeatherResult {
20
20
 
21
21
  export type FetchFn = typeof fetch;
22
22
 
23
+ export type ParsedLocation = {
24
+ city?: string;
25
+ country?: string;
26
+ };
27
+
28
+ const DIGIT_WORDS: Record<string, string> = {
29
+ zero: "0",
30
+ oh: "0",
31
+ o: "0",
32
+ one: "1",
33
+ two: "2",
34
+ three: "3",
35
+ four: "4",
36
+ five: "5",
37
+ six: "6",
38
+ seven: "7",
39
+ eight: "8",
40
+ nine: "9",
41
+ };
42
+
43
+ /** Lowercase aliases → Open-Meteo-friendly country names. */
44
+ const COUNTRY_ALIASES: Record<string, string> = {
45
+ thailand: "Thailand",
46
+ us: "United States",
47
+ usa: "United States",
48
+ america: "United States",
49
+ "united states": "United States",
50
+ "united states of america": "United States",
51
+ uk: "United Kingdom",
52
+ britain: "United Kingdom",
53
+ england: "United Kingdom",
54
+ "united kingdom": "United Kingdom",
55
+ "great britain": "United Kingdom",
56
+ germany: "Germany",
57
+ france: "France",
58
+ spain: "Spain",
59
+ italy: "Italy",
60
+ japan: "Japan",
61
+ china: "China",
62
+ india: "India",
63
+ australia: "Australia",
64
+ canada: "Canada",
65
+ brazil: "Brazil",
66
+ mexico: "Mexico",
67
+ netherlands: "Netherlands",
68
+ holland: "Netherlands",
69
+ "the netherlands": "Netherlands",
70
+ belgium: "Belgium",
71
+ switzerland: "Switzerland",
72
+ sweden: "Sweden",
73
+ norway: "Norway",
74
+ denmark: "Denmark",
75
+ finland: "Finland",
76
+ poland: "Poland",
77
+ portugal: "Portugal",
78
+ greece: "Greece",
79
+ turkey: "Turkey",
80
+ egypt: "Egypt",
81
+ "south africa": "South Africa",
82
+ "new zealand": "New Zealand",
83
+ ireland: "Ireland",
84
+ singapore: "Singapore",
85
+ malaysia: "Malaysia",
86
+ indonesia: "Indonesia",
87
+ vietnam: "Vietnam",
88
+ philippines: "Philippines",
89
+ "south korea": "South Korea",
90
+ korea: "South Korea",
91
+ taiwan: "Taiwan",
92
+ "hong kong": "Hong Kong",
93
+ israel: "Israel",
94
+ uae: "United Arab Emirates",
95
+ "united arab emirates": "United Arab Emirates",
96
+ "saudi arabia": "Saudi Arabia",
97
+ pakistan: "Pakistan",
98
+ bangladesh: "Bangladesh",
99
+ nigeria: "Nigeria",
100
+ kenya: "Kenya",
101
+ argentina: "Argentina",
102
+ chile: "Chile",
103
+ colombia: "Colombia",
104
+ peru: "Peru",
105
+ austria: "Austria",
106
+ "czech republic": "Czechia",
107
+ czechia: "Czechia",
108
+ romania: "Romania",
109
+ hungary: "Hungary",
110
+ ukraine: "Ukraine",
111
+ };
112
+
113
+ export function matchCountryName(text: string): string | null {
114
+ const key = text
115
+ .trim()
116
+ .toLowerCase()
117
+ .replace(/[.,!?]+$/g, "")
118
+ .replace(/\s+/g, " ");
119
+ if (!key) return null;
120
+ return COUNTRY_ALIASES[key] ?? null;
121
+ }
122
+
123
+ /** "eight four three two zero" → "84320" (4–6 digits). */
124
+ export function spokenDigitsToPostal(text: string): string | null {
125
+ const tokens = text
126
+ .toLowerCase()
127
+ .split(/[^a-z0-9]+/)
128
+ .filter(Boolean);
129
+ const digits: string[] = [];
130
+ for (const token of tokens) {
131
+ if (/^\d$/.test(token)) {
132
+ digits.push(token);
133
+ continue;
134
+ }
135
+ const mapped = DIGIT_WORDS[token];
136
+ if (mapped) {
137
+ digits.push(mapped);
138
+ }
139
+ // Skip STT filler ("welcome", "down", "there", …).
140
+ }
141
+ if (digits.length >= 4 && digits.length <= 6) {
142
+ return digits.join("");
143
+ }
144
+ return null;
145
+ }
146
+
147
+ function splitTrailingCountry(
148
+ text: string,
149
+ ): { rest: string; country: string } | null {
150
+ const words = text.trim().split(/\s+/).filter(Boolean);
151
+ if (words.length < 2) return null;
152
+ for (let n = Math.min(3, words.length - 1); n >= 1; n -= 1) {
153
+ const tail = words.slice(-n).join(" ");
154
+ const country = matchCountryName(tail);
155
+ if (country) {
156
+ return { rest: words.slice(0, -n).join(" "), country };
157
+ }
158
+ }
159
+ return null;
160
+ }
161
+
162
+ function cityFromRemainder(rest: string): string | null {
163
+ const trimmed = rest
164
+ .trim()
165
+ .replace(/[.,!?;:]+$/g, "")
166
+ .replace(/\s+(?:in the|in|of)$/i, "")
167
+ .trim();
168
+ if (!trimmed) return null;
169
+ if (/^\d{4,6}(-\d{4})?$/.test(trimmed)) {
170
+ return trimmed;
171
+ }
172
+ const spoken = spokenDigitsToPostal(trimmed);
173
+ if (spoken) return spoken;
174
+ if (trimmed.length >= 2 && trimmed.length <= 60) {
175
+ return trimmed;
176
+ }
177
+ return null;
178
+ }
179
+
23
180
  /** Map WMO weather_code to a short English phrase. */
24
181
  export function wmoCodeToPhrase(code: number): string {
25
182
  if (code === 0) return "clear sky";
@@ -43,20 +200,36 @@ export function formatWeatherSpeech(result: WeatherResult): string {
43
200
  /** Parse city/zip and country from a single utterance when possible. */
44
201
  export function parseLocationUtterance(
45
202
  utterance: string,
46
- ): { city: string; country?: string } | null {
203
+ ): ParsedLocation | null {
47
204
  const text = utterance.trim();
48
205
  if (!text) return null;
49
206
 
207
+ const countryOnly = matchCountryName(text);
208
+ if (countryOnly) {
209
+ return { country: countryOnly };
210
+ }
211
+
212
+ const trailing = splitTrailingCountry(text);
213
+ if (trailing) {
214
+ const city = cityFromRemainder(trailing.rest);
215
+ if (city) {
216
+ return { city, country: trailing.country };
217
+ }
218
+ return { country: trailing.country };
219
+ }
220
+
50
221
  const inMatch = text.match(
51
222
  /^(?:in\s+)?(.+?)\s+in\s+([a-zA-Z][\w\s.-]{1,40})$/i,
52
223
  );
53
224
  if (inMatch) {
54
- return { city: inMatch[1]!.trim(), country: inMatch[2]!.trim() };
225
+ const country = matchCountryName(inMatch[2]!) ?? inMatch[2]!.trim();
226
+ return { city: inMatch[1]!.trim(), country };
55
227
  }
56
228
 
57
229
  const commaMatch = text.match(/^(.+?),\s*([a-zA-Z][\w\s.-]{1,40})$/);
58
230
  if (commaMatch) {
59
- return { city: commaMatch[1]!.trim(), country: commaMatch[2]!.trim() };
231
+ const country = matchCountryName(commaMatch[2]!) ?? commaMatch[2]!.trim();
232
+ return { city: commaMatch[1]!.trim(), country };
60
233
  }
61
234
 
62
235
  const countryMatch = text.match(
@@ -64,16 +237,22 @@ export function parseLocationUtterance(
64
237
  );
65
238
  if (countryMatch && countryMatch[2]!.split(/\s+/).length <= 3) {
66
239
  const city = countryMatch[1]!.trim();
67
- const country = countryMatch[2]!.trim();
240
+ const countryRaw = countryMatch[2]!.trim();
241
+ const country = matchCountryName(countryRaw) ?? countryRaw;
68
242
  if (city.length >= 2 && country.length >= 2) {
69
243
  return { city, country };
70
244
  }
71
245
  }
72
246
 
73
- if (/^\d{5}(-\d{4})?$/.test(text)) {
247
+ if (/^\d{4,6}(-\d{4})?$/.test(text)) {
74
248
  return { city: text };
75
249
  }
76
250
 
251
+ const spokenPostal = spokenDigitsToPostal(text);
252
+ if (spokenPostal) {
253
+ return { city: spokenPostal };
254
+ }
255
+
77
256
  if (text.length >= 2 && text.length <= 60) {
78
257
  return { city: text };
79
258
  }