@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.
- package/dist/templates/game-sync/agent.js +116 -49
- package/dist/templates/voice-showcase/agent.js +165 -10
- package/package.json +1 -1
- package/templates/game-sync-protocol.ts +13 -0
- package/templates/game-sync-world-layout.ts +27 -0
- package/templates/game-sync.ts +114 -60
- package/templates/voice-showcase/conversation.ts +17 -6
- package/templates/voice-showcase/weather.ts +184 -5
|
@@ -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
|
-
|
|
11825
|
-
|
|
11882
|
+
const target = resolveRemoveTarget(objectId, owned);
|
|
11883
|
+
if (!target.ok) {
|
|
11884
|
+
return target;
|
|
11826
11885
|
}
|
|
11827
|
-
|
|
11828
|
-
|
|
11829
|
-
|
|
11830
|
-
if (
|
|
11831
|
-
return
|
|
11832
|
-
}
|
|
11833
|
-
|
|
11834
|
-
|
|
11835
|
-
|
|
11836
|
-
|
|
11837
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11897
|
-
|
|
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
|
-
|
|
11902
|
-
|
|
11903
|
-
|
|
11904
|
-
|
|
11905
|
-
|
|
11906
|
-
|
|
11907
|
-
|
|
11908
|
-
|
|
11909
|
-
|
|
11910
|
-
|
|
11911
|
-
|
|
11912
|
-
|
|
11913
|
-
|
|
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
|
|
11984
|
-
|
|
11985
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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{
|
|
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
|
-
|
|
1063
|
-
|
|
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
|
|
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:
|
|
1244
|
+
state: { ...state, weatherCity: city, weatherCountry: country },
|
|
1090
1245
|
speakLines: [],
|
|
1091
1246
|
messages: [],
|
|
1092
|
-
pendingWeather: { city, country
|
|
1247
|
+
pendingWeather: { city, country }
|
|
1093
1248
|
};
|
|
1094
1249
|
}
|
|
1095
1250
|
case "countAwaitingNumber": {
|
package/package.json
CHANGED
|
@@ -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) {
|
package/templates/game-sync.ts
CHANGED
|
@@ -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`).
|
|
11
|
-
*
|
|
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: "
|
|
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
|
|
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
|
-
|
|
246
|
-
|
|
296
|
+
const target = resolveRemoveTarget(objectId, owned);
|
|
297
|
+
if (!target.ok) {
|
|
298
|
+
return target;
|
|
247
299
|
}
|
|
248
300
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (
|
|
253
|
-
return
|
|
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
|
-
|
|
332
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
431
|
-
|
|
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 =
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
404
|
-
|
|
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
|
|
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:
|
|
444
|
+
state: { ...state, weatherCity: city, weatherCountry: country },
|
|
434
445
|
speakLines: [],
|
|
435
446
|
messages: [],
|
|
436
|
-
pendingWeather: { city, country
|
|
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
|
-
):
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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{
|
|
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
|
}
|