AI Control Bridge (TLA)
The AI control bridge lets an external tool (an MCP host / model) observe and control a real TLA client over a localhost TCP socket, without giving server/admin powers. One bridge connection controls one client; commands become ordinary client actions and are still validated server-side through the normal RPC flow (cooldowns, visibility, range, item ownership, dialog state).
This is a foundation port adapted from the sibling project (H:/lf-29). The native bridge is shared
verbatim; the script layer (Scripts/AiControl.fos) was rewritten against TLA’s own client API. It covers
observation, events, the core player-control commands, register/login, and dialog observation, all verified
end-to-end against a running client. Higher-level features that depend on systems TLA does not yet expose
the same way (roster, admin prep, factions, overwatch, global-map interests, the advisory/agent-runtime
layer, content-specific playtest runners) are added incrementally — see the roadmap at the end.
Architecture
Two layers:
Scripts/AiControl.fos(CLIENT) builds structured observation snapshots, records client-side events, and executes semantic commands through normal player-control paths (ChosenActions,CurPlayer.ServerCall).SourceExt/ClientAiBridge.cpp(CLIENT) exposes a localhost TCP line protocol (one JSON object per line). It is enabled per client and stores its state inClientExtData(SourceExt/ClientExtension.h), which is allocated in theClientInitHookengine hook.
Tools/AiControlMcp/ai_control_mcp.py wraps that TCP protocol as a stdio MCP server for model hosts. The
embedded-client index used for multi-client port spacing comes from Game.GetEmbeddedClientIndex()
(SourceExt/ClientExtension.cpp).
The server still validates every resulting gameplay request normally; the bridge is not a server-authority bypass.
Configuration
Declared in Scripts/AiControl.fos (///@ Setting Client ...) with defaults in TLA.fomain:
| Setting | Default | Purpose |
|---|---|---|
AiControl.Enabled |
False |
Start the client TCP bridge when the client boots (Game.OnStart) |
AiControl.Host |
127.0.0.1 |
Bind address for the TCP listener |
AiControl.Port |
43011 |
TCP port used by external adapters |
AiControl.PortStride |
1 |
Port spacing for embedded multi-client launches: client index N binds Port + (N-1)*PortStride |
AiControl.Token |
empty | Optional shared token required before non-auth bridge calls |
AiControl.MaxQueuedCommands |
64 |
Back-pressure limit for queued commands |
AiControl.MaxEvents |
512 |
Ring-buffer size for the client event history |
AiControl.ObservationInterval |
250 |
Milliseconds between observation snapshots |
AiControl.MaxObservedEntities |
80 |
Per-snapshot cap for visible critters / map items / inventory |
AiControl.SkipRosterOnLogin |
False |
Reserved (roster not ported in v1) |
AiControl.ProfileObservationThresholdMs |
0 |
Reserved (profiling not ported in v1) |
AiControl.CaptureRawInputEvents |
False |
Reserved (raw input capture not ported in v1) |
AiControl.AllowQaCommands |
False |
Gate (Common, read on client+server) for the qa_* setup/fixture commands. Keep off outside test harnesses. |
Keep the listener on loopback. It binds the local client as a remotely controllable process if exposed.
Native bridge protocol
ClientAiBridge.cpp listens for newline-delimited JSON-RPC-like messages. Request shape:
{"jsonrpc":"2.0","id":1,"method":"observe","params":{}}
| Method | Params | Result |
|---|---|---|
auth |
{ "token": "..." } |
{ "authorized": true/false } |
ping |
{} |
{ "ok": true } |
status |
{} |
Queue sizes, host/port, observation sequence, last native error |
observe |
{} |
Latest structured observation snapshot |
events |
{ "afterSeq": 0, "limit": 100 } |
Client events after a sequence number |
act |
command object | Enqueues a command for the next client loop |
If AiControl.Token is non-empty, call auth first on every connection. The MCP adapter does this
automatically when TLA_AI_TOKEN / --token is supplied.
Observation (schemaVersion 1)
Top-level keys: schemaVersion, seq, clientIndex, connected, hasMap, hasChosen, account,
screen, mouse, chosen, map, critters, mapItems, inventory, quests, dialog,
availableActions.
Consumers must branch on connected, hasMap, hasChosen instead of assuming a current player/map/chosen.
chosen: id, name, hexX/Y, level, currentHp, maxHp, experience, currentAp, actionPoints, isAlive, inCombat (TimeoutBattleactive), inSneakMode.map: id, protoId, width, height.critters: visible critters fromCurMap.GetCritters(CritterFindType::Any)(chosen excluded): id, protoId, name, hexX/Y, isAlive, inCombat, isNoTalk, effective talkDistance, dialogId. TheprotoId+dialogIdpair lets quest tools distinguish authored NPC roles that happen to share a dialog;isNoTalkandtalkDistanceexplain silent client/server talk precondition failures.- In QA-enabled runs, an ordinary
talk_toattempt also emitstalk_diagnostic: the server reports life/control/no-talk/map/distance/mutual-visibility checks and whether the normal dialog context actually started. This is observational only; it calls the sameStartDialog/Dialogs::RunDialogpath and does not bypass distance, visibility, demands, or synchronization. mapItems: visible map items tracked fromOnItemMapIn/OnItemMapOut: id, protoId, hexX/Y, count, stackability, ownership, cost/weight, use/pick-up capabilities, andvisualFeedback.timerCapable.inventory:Chosen.GetItems(): the same real item metadata plus slot. Both item arrays also expose the TLA door/container contract (hasDoor,hasContainer,hasLocker,canOpen,opened, lock/jam/broken/ no-open state, andisGag). Runners must select candidates from these fields instead of guessing safety from a proto name or accepting an unchecked explicit id.barter: active barter snapshot withtraderId, transfersession, server-synchronized pricing metadata (coefficient,masterTrader,playerOfferTotal,traderOfferTotal), plusplayerInventory,playerOffer,traderInventory, andtraderOfferitem arrays (id,protoId,count);active=falseoutside Barter.quests: active quests of the chosen. TLA models quests as per-questuint8critter properties in theQuestsgroup (value = stage, 0 = not started), not aQuestProgressarray — so this lists the non-zero quest properties as{ name, value }. (Resolved quest titles/objective text are a later addition.)screen: active screen name,modalActive(Gui::IsModalScreenActive()), and the active-screen list.dialog:{ active, dialogId, talkerId, text, answers }. Captured fromDialogs.fos::ReceiveDialogContext(the data source, before the screenparamsdict is consumed byGui::ShowScreen) viaAiControl::SetDialogState/ClearDialogState. Drive dialogs by answer index withdialog_answer.text/answerscarry the resolved speech/answer strings (see the dialog text-key fix note below).availableActions: the list of supported command types (richer affordance hints are adapter-side).
Events
Pushed for client lifecycle/world changes: bridge_started / bridge_stopped, connecting /
connecting_failed / connected / disconnected, login_success, info_message, input_lost,
map_load / map_unload, critter_in / critter_out, item_map_in / item_map_out,
item_inv_in / item_inv_out, receive_items, screen_change, console_message, and the native
command_completed. The native bridge also emits runtime_exception events when the engine log shows
script exceptions, assertions, or fatal/error markers.
The event buffer is in-memory only. Keep the latest seq from events and ask for events after it.
Commands
Queued through act, consumed by the client loop, routed through normal player intent:
| Type | Main params | Behavior |
|---|---|---|
register |
optional stringArg (name) |
Register a fresh character and enter the game through PlayerRegistration (default name TestBot<clientIndex>; builds a valid SPECIAL/skills/traits set). Drives the normal Game.Connect + RegisterNewPlayer path without TryExit. |
login |
optional stringArg (name) |
Log in an existing character through LoginExistingPlayer. |
move_to_hex |
x, y, optional intArg (cut distance), append |
Tla::ChosenMove |
talk_to |
targetId, append |
Tla::ChosenTalkNpc |
loot_critter |
targetId, append |
Tla::ChosenPickCrit |
attack_entity |
targetId, intArg (mode) |
CurPlayer.ServerCall.Attack |
pick_item |
itemId, append |
Tla::ChosenPickItem (proto/hex resolved from the item) |
pick_hex |
x, y, append |
Pick a visible map item at the hex |
use_item |
itemId, optional exactly one targetId/auxId, optional stringArg (timer:<seconds>) |
Tla::ChosenUseItem on self, one critter, or one item. Timer mode is self-only and accepts only the canonical range timer:1 through timer:599 (no sign, whitespace, leading zeroes, target, or trailing text). |
use_skill |
stringArg (skill CritterProperty name, e.g. SkillFirstAid/SkillLockpick/SkillRepair), optional targetId/itemId/x/y; static scenery also requires sceneryProtoId |
CurPlayer.ServerCall.UseSkill. With no target it applies to the chosen (self-heal etc.); scenery proto and hex are transported separately. |
reload |
itemId, auxId (ammo) |
CurPlayer.ServerCall.ReloadWeapon |
unload |
itemId |
Tla::ChosenUnloadWeapon |
move_item |
itemId, intArg (slot) |
CurPlayer.ServerCall.MoveInvItem |
drop_item |
itemId, intArg (count) |
CurPlayer.ServerCall.DropInvItem |
operate_container |
itemId, intArg (bit0 take, bit1 all, >>2 count) |
CurPlayer.ServerCall.OperateContainer (loot/container grid: take items) |
craft |
intArg (craft id) |
CurPlayer.ServerCall.Rpc_CraftItem(FixboyButton, id, 0) — runs a FixBoy recipe (server checks skill/materials/tools, consumes resources, grants the item) |
toggle_sneak |
append |
Tla::ChosenSneak — sneak is stubbed in TLA, returns sneak_not_implemented_in_game |
dialog_answer |
intArg (answer index; special links: 0xF1=close, 0xF2=barter) |
CurPlayer.ServerCall.SpeechAnswer |
close_dialog |
none | Semantically close the active dialog with SpeechAnswer(0xF1) |
say |
stringArg, intArg (SayType) |
CurPlayer.ServerCall.ReceiveChosenSay |
barter_transfer |
itemId, stringArg source, positive intArg count |
Move a stack between player_inventory/player_offer or trader_inventory/trader_offer in the active Barter UI |
barter_offer |
none | Submit the assembled active barter offer through the normal client/server path |
barter_return_dialog |
none | Return from active NPC barter to its dialog through the current validated transfer session |
clear_actions |
none | Clear queued client actions |
close_screen / show_screen / hide_screen |
stringArg (GuiScreen name) |
GUI screen control |
show_context_screen |
stringArg (Aim, Use, Timer, Split, or SkillBox), real item/target ids as required |
Open a contextual screen with its normal OnShow parameter contract after validating the observed target, item ownership, stack, and timer/use capabilities. |
ui_answer |
intArg (index) or stringArg (answer_N/level_N); auxId is the expected DialogBox session |
Answer a semantic DialogBox/elevator prompt. DialogBox requires the session from the same observation and rejects a missing/stale session; elevator answers reject a DialogBox session. |
save_screenshot |
stringArg (path) |
Game.SaveScreenshot (framebuffer readback) |
set_resolution |
x, y |
Game.SetResolution |
toggle_fullscreen |
none | Game.ToggleFullscreen |
set_mouse_pos |
screenX, screenY |
Game.SetForcedMousePos |
mouse_click |
screenX, screenY, intArg (MouseButton) |
Game.SimulateMouseClick |
key_press |
intArg (KeyCode), stringArg |
Game.SimulateKeyboardPress |
environment_query |
intArg (queryId), screenX/screenY (from-hex, -1 = chosen), x/y (to-hex), stringArg (options) |
Client geometry/path query; publishes an environment_query_result event by queryId. |
Commands not yet supported in TLA return unsupported:<type> and success=false.
QA setup commands (test-only)
Gated by AiControl.AllowQaCommands = True (off by default, checked on both client and server). They route
through server RemoteCalls that move the player’s controlled critter; they are a test-harness setup surface,
not normal player abilities. Use them to reach content for mechanics/quest runs.
| Type | Main params | Behavior |
|---|---|---|
qa_teleport_hex |
x, y |
Same-map reposition (Critter.TransferToHex); the engine snaps to the nearest movable hex. Useful to reach otherwise-blocked spots. |
qa_teleport_map |
stringArg (locationPid or locationPid/mapProto), optional x/y |
Teleport to a content map. The client validates both proto ids and sends them as separate known hstring values; the server resolves the requested map inside that location, creates a valid unloaded location when needed, and uses entry "0" when no hex is given. Unknown targets fail as unknown_map_target without a server exception. |
qa_teleport_global |
none | Critter.TransferToGlobal. Note: a no-op from the repl1 replication/limbo start map (it has no global coordinates); use qa_teleport_map to reach content. |
qa_set_prop |
stringArg (CritterProperty name), intArg (value), optional x request id |
Set an int CRITTER property (cr.SetAsInt) — reputation/loyalty values, a prior quest stage, CurrentHp, limb-damage flags, etc. A successful script-side write publishes qa_prop_set ({prop, value, requestId}); a missing/delayed matching event is retried by the caller. |
qa_set_game_prop |
stringArg (GameProperty name), intArg (value) |
Set an int GAME property (Game.SetAsInt) — world/quest flags that live on the game singleton, not the critter (e.g. DenVirginIsAway). Many dialog demands gate on these, so qa_set_prop alone can’t satisfy them. |
qa_give_item |
stringArg (item proto), intArg (count) |
Give an item (cr.AddItem) — for quest items the giver doesn’t hand out, or starting gear. |
qa_get_prop |
stringArg (CritterProperty name), optional intArg request id |
Authoritative SERVER-side read of an int critter property. The server reads cr.GetAsInt and calls the client back (AiControlReceiveQaProp), which publishes a qa_prop_value event ({prop, value, requestId}). Needed because ~1/3 of quest flags are Server-scope (not OwnerSync) and therefore never appear in the client observation’s quests — the only way to verify them is this round-trip. |
qa_get_text |
stringArg (dialog id), intArg (string number) |
Resolve a numbered NPC floating-text via MsgStr::DialogTextKey and return it in the command message (text=<…>). Debug tool for the numbered-text fix below; empty means the string isn’t authored for that dialog. |
qa_get_text_pack |
intArg (numeric id) |
Resolve a row from the baked general Text pack for the client’s active language via TextPackKey(TextPackName::Text, …) and return it as text=<…>. This verifies the runtime localization surface rather than only the authored .fotxt file. |
qa_format_tags |
stringArg (text with tags) |
Run a raw string through Game.FormatTags (resolves @text/@arg/@sex/… client-side) and return the result (result=<…>). Used to verify the three-token @text Dialogs <dialogName> <key>@ path end-to-end. |
qa_show_dialog_box |
none | Request a real server-backed AskFollowGlobalGroupRuler DialogBox with two answers for semantic/session and screenshot tests. The typed MCP wrapper is tla_qa_show_dialog_box; answer_1 is the safe no-op choice. |
qa_get_prop is asynchronous: the value arrives as a qa_prop_value event, not in the command’s own
completion message. Snapshot the event cursor (max seq), send qa_get_prop with a non-zero request id, then
poll events for the qa_prop_value whose prop and requestId both match. The correlation is required under
load because a delayed response to an earlier read may arrive after the new cursor. tla_quest_runner.py’s
read_quest does exactly this — it reads the client observation first (fast, for OwnerSync quests) and falls
back to a correlated qa_get_prop when the property is absent client-side (Server-scope quests such as
KlamVaccination).
qa_set_prop has the same correlation requirement, with the request id transported in x because intArg
already carries the property value. tla_quest_runner.py waits for a matching qa_prop_set event and resends
the command when no matching acknowledgement arrives. The acknowledgement is sent only after SetAsInt.
Entity locking remains an explicit Game.Sync cover in the [[Async]] script callback; no called engine
function performs implicit synchronization.
qa_teleport_map accepts locationPid/mapProto (e.g. vault_city/vcity_courtyard) to land on a specific
sub-map of a multi-map location. The split is intentional: a synthetic combined hstring is not part of baked
metadata and would be rejected by engine inbound validation. qa_set_prop / qa_give_item are the
prerequisite-setup surface for quests gated by
reputation, faction loyalty, prior quest state, or required items — set those up, then run the quest’s normal
dialog flow.
qa_teleport_map is the practical way to put a test character on a real content map (e.g.
qa_teleport_map arroyo lands on the arroyo village map with its real NPCs). A freshly created location instance is
a sandbox copy of the proto’s maps/NPCs — good for exercising map mechanics/dialogs, but not tied to the
canonical world location’s quest state.
Environment queries
environment_query runs advisory geometry/path checks against the currently replicated map and publishes
an environment_query_result event matched by queryId. Options are "kind;name=value;...":
path— reachability from a hex (or the chosen) to a target hex:reachable,pathLength,directDistance,fromMovable/toMovable, anddirections/pathHexes. Use it to skip unreachable targets before issuingmove_to_hex/talk_to/pick_item.obstacles— scan a radius (radius=,maxResults=) around a center hex and list non-movable / non-shootable / occupied hexes.trace— trace a line toward a target (maxDistance=,angle=) and report where it stops and whether it reached the target.
The adapter wraps these as tla_env_path / tla_env_obstacles / tla_env_trace.
MCP adapter
Tools/AiControlMcp/ai_control_mcp.py is a dependency-free stdio MCP adapter (ported from the sibling
project, renamed to the tla_ / TLA_AI_* / tla:// namespace). Supporting modules:
ai_control_runner.py (stdio MCP client + command/observation/launch/log helpers), ai_control_advisory.py,
ai_control_guides.py, ai_control_launch.py (launch manifest from TLA.fomain + .vscode/tasks.json),
ai_control_protocol.py. smoke_ai_control_mcp.py is the smoke runner.
Environment variables: TLA_AI_HOST (127.0.0.1), TLA_AI_PORT (43011), TLA_AI_TOKEN (empty),
TLA_AI_TIMEOUT (3), TLA_WORKSPACE_ROOT (adapter grandparent).
Core MCP tools include tla_ping, tla_status, tla_observe, tla_step, tla_sync, tla_events,
tla_next_events, tla_act, tla_clear_actions, the typed command tools (tla_move_to_hex, tla_talk_to,
tla_attack_entity, tla_pick_item, tla_use_item, tla_use_skill, tla_craft, tla_barter_transfer,
tla_barter_offer, tla_barter_return_dialog, tla_close_dialog, tla_show_context_screen,
tla_ui_answer, tla_qa_show_dialog_box, …), launch tools
(tla_launch*, tla_processes, tla_logs), and the static schema/guide resources. Many advisory/agent
tools were carried over from the sibling project and still assume sibling-project content; treat them as
provisional until adapted to TLA content.
TLA observations expose map positions as flat hexX / hexY fields. The navigation adapter normalizes those
fields to its generic hex: {x, y} shape for the chosen critter, visible critters, and map items before planning.
The current TLA bridge implements path, but not the sibling project’s tactical_path query. tla_nav_plan and
tla_find_safe_step therefore fall back to path only when the bridge explicitly reports
unknown_environment_query or unsupported_environment_query; the result records queryFallback, while every
other environment-query failure is returned unchanged.
Verified engine screenshots
tla_save_screenshot captures the composed render target (world plus GUI). Use a standalone graphical client for
visual regression runs: the embedded headless client produced structurally valid TGA files whose framebuffer was
entirely black in the verified Windows setup. The headless mode remains useful for non-visual bridge/gameplay
automation. The tool always waits for its own command_completed; callers do not need to set
waitForCompletion. Before capture it optionally waits settleMs (250 ms by default), removes an old target,
and confines the optional .tga path to TLA_WORKSPACE_ROOT. With no path it writes a timestamped file under
Workspace/AiControlScreenshots/.
The MCP capture result is a verification record, not just an acknowledgement: absolute and workspace-relative paths,
existence/byte size, dimensions, pixel depth, SHA-256, TGA payload boundaries, sampled color/luminance metrics,
blankLike, command completion, and verified. verified=true means command completion succeeded, the new file
is an uncompressed 24/32-bit true-color TGA with a complete payload, and the sampled frame is not blank-like.
Bad input (wrong extension or a path escape) is MCP -32602; command/file/format/blank failures are returned as
verified=false with a structured failure.stage and failure.message. This generic check proves that a frame
was produced, not that a particular window rendered its controls; the audit runners add content-specific ROI
oracles below.
For deterministic screen audits, direct tla_show_screen is safe only for parameterless screens in the right
game state:
| Audit path | Screens | Notes |
|---|---|---|
| Direct show/capture/hide while in game | Options, Inventory, Character, PipBoy, FixBoy, Menu, Credits |
No required params; inventory/character/PDA/crafting still need a chosen critter for meaningful content |
| Open through the real mechanic, then capture | Dialog, Barter, PickUp, Split, Aim, Radio, Timer, Use, SkillBox, Elevator, TownView, GmTown, Say, SayExtended, DialogBox, InputBox |
Their screen code consumes a target/item/location/dialog context; direct show can assert or render invalid state |
| State-owned roots | Login, Registration, Game, GlobalMap, Wait |
Reach these through connection, registration, map transfer, or wait state rather than stacking them over gameplay |
The standalone audit runner uses the shared MCP client, waits for show/hide completion, checks that each screen
became active, and applies a content oracle: controls for Options, stats for Inventory/Character, PipBoy body,
FixBoy recipes, Menu buttons, and Credits text. Unknown screens are not marked verified unless the diagnostic
--allow-generic opt-in is supplied. The runner writes a JSON manifest:
python Tools/AiControlMcp/tla_gui_screenshot_test.py
python Tools/AiControlMcp/tla_gui_screenshot_test.py --screens Options,Inventory,PipBoy \
--output-dir Workspace/AiControlScreenshots/gui-smoke
tla_context_gui_playtest.py covers nine parameterized windows: SkillBox, Aim, Split, Timer,
Use, PickUp, Radio, Elevator, and DialogBox. It selects real observed items/targets using the
capability metadata above, opens the first five through tla_show_context_screen, opens PickUp and Radio
through their normal mechanics, captures Elevator when already active or enters it through an authored trigger
hex, and obtains DialogBox from tla_qa_show_dialog_box. Each capture has a window-specific ROI oracle.
The Aim oracle checks its authored green labels rather than the gold text used by several other windows.
DialogBox is closed with tla_ui_answer using expectedSession; a delayed answer cannot act on a replacement
prompt. Its semantic buttons also expose safety metadata: answer_0 is role=confirm, dangerous=true, while
answer_1 is the safe role=cancel choice. Agents must not treat an unlabelled first button as a harmless default.
The default run captures every context that is available in the current world state. --require-all turns a
missing prerequisite into failure. Elevator needs a known real trigger unless it is already active:
python Tools/AiControlMcp/tla_context_gui_playtest.py \
--output-dir Workspace/AiControlScreenshots/context-gui
# First use tla_qa_teleport_map with mariposa/mariposa_level1.
python Tools/AiControlMcp/tla_context_gui_playtest.py --screens Elevator \
--elevator-trigger-hex 101 43 --require-all
A live standalone-client run passed the eight Arroyo contexts available there plus the Mariposa Elevator run
(9/9 total). Timer was also verified beyond pixels: timer:599 consumed one inventory dynamite and created
one active_dynamite. The elevator exposed level_1 through level_3; answering level_2 through
tla_ui_answer transferred the chosen from mariposa_level1 to mariposa_level2.
The runner deliberately does not bypass item safety: Split needs an owned stack, Timer an owned timer-capable
item, Use an owned canUseOnSmth item and a real target, PickUp a visible safe unlocked container, and Radio
an owned radio. Prepare those prerequisites through ordinary gameplay or gated QA setup commands.
tla_barter_playtest.py covers the contextual Dialog → NPC Barter → Dialog lifecycle through MCP
tools. It requires an in-game chosen critter and AiControl.AllowQaCommands=True; the runner grants caps,
teleports to the requested trader, verifies pricing/session metadata and a completed offer refresh, captures all
three UI states, and requires the assembled Barter frame to contain all four item panels, both totals, and the
header display. It closes the dialog and writes report.json alongside the verified TGA files:
python Tools/AiControlMcp/tla_barter_playtest.py --map arroyo --npc-dialog-id arroyo_doc --hex 77 103 \
--output Workspace/AiControlBarterPlaytests/arroyo-doc
# Add --hex X Y when the trader is not visible from the map entry.
Use tla_window_screenshot only when the OS window itself matters (window chrome, presentation under a specific
desktop compositor, or a renderer diagnostic); it is Windows-only and is not the canonical composed-frame check.
NOTE: The adapter’s launch orchestration reads TLA subconfigs (
Unpackaged,LocalTest,PublicGame). TLA does not use the sibling project’s scene system, sostartupScenesis empty. For now, launch the game manually (below) and point the adapter at the running bridge.
Running it locally
Build once: Build :: TLA_Client (compiles the native bridge) and Bake Resources.
Recommended: persistent server + windowed client
This is the robust setup for interactive testing — the server stays up regardless of client login state.
# 1) Persistent headless server (no embedded client), stays up:
cmake --build Build/Auto --config RelWithDebInfo --target TLA_ServerHeadless
./Binaries/Server-Windows-win64/TLA_ServerHeadless.exe --ApplySubConfig LocalTest # wait for "Start server complete!"
# 2) A client that hosts the bridge on 127.0.0.1:43011 (windowed; D3D11 works over RDP):
./Binaries/Client-Windows-win64/TLA_Client.exe --ApplySubConfig LocalTest --AiControl.Enabled True
Then drive it: connect to the bridge, send register (a fresh character spawns and enters the game), and
poll observe until hasChosen / hasMap are true. On the same persistent server (in-memory DB) a name
can be registered once; use login to re-enter an existing character after a client relaunch.
One-shot headless (embedded client)
./Binaries/Server-Windows-win64/TLA_ServerHeadless.exe \
--ApplySubConfig LocalTest --AiControl.Enabled True --Server.AutoStartClientOnServer 1
The embedded headless client boots, fires Game.OnStart, and starts the bridge. This mode is one-shot:
the headless app quits when its embedded client disconnects (e.g. a failed/finished login), so it is best
for a single scripted run, not long iterative sessions. Use the persistent-server setup above for those.
Validation
Build :: TLA_Client— native bridge compiles/links (rebuilds the generated script API for the new///@ ExportMethod////@ EngineHook). For headless smoke also buildTLA_ServerHeadless.Compile AngelScript(orBake Resources) —Scripts/AiControl.fossees the generated client exports.python Tools/AiControlMcp/smoke_ai_control_mcp.py --static-only— schema/tool/resource/prompt discovery.python -m unittest Tools/AiControlMcp/test_ai_control_screenshot.py— path confinement, raw TGA validation, blank-like detection, event metadata, and typed command payload checks.- Launch a client with
AiControl.Enabled = True(above), thenpython Tools/AiControlMcp/smoke_ai_control_mcp.py—tla_ping,tla_status,tla_observe, the event cursor, and one harmlesstla_clear_actionsround-trip. - With an in-game chosen critter, run
python Tools/AiControlMcp/tla_gui_screenshot_test.pyand inspect its manifest plus TGA files. Runpython Tools/AiControlMcp/tla_context_gui_playtest.pyfor the nine contextual windows; use--require-allonly after preparing every real item/map prerequisite.
Playtest runner
Tools/AiControlMcp/tla_mechanics_playtest.py is a self-contained (no MCP adapter needed) reachability-aware
mechanics run over the bridge: it logs in (or --register), summarizes the observation, reachability-probes
visible critters/items with environment_query path, navigates to the nearest reachable targets, attempts a
talk, and writes a JSON report (--report). Run it against a client started as above:
python Tools/AiControlMcp/tla_mechanics_playtest.py --name TestBot1 --register --report Workspace/run.json
# run on a real content map (needs AiControl.AllowQaCommands=True on server+client):
python Tools/AiControlMcp/tla_mechanics_playtest.py --register --teleport-map arroyo --report Workspace/arroyo.json
Tools/AiControlMcp/tla_quest_runner.py runs a full quest cycle (accept → travel → turn in) end-to-end,
asserting the quest property advances at each stage. Quest specs are data-driven (QUESTS dict). Four are
verified across three towns and three shapes: cassidy_letter (Arroyo → Vault City delivery),
arroyo_mynoc_oil (accept + turn-in on one giver), den_smitty_robot (Den: accept → repair the Mr. Handy at
a workbench → report, skill + item gated, four stages) and klam_vaccination (Klamath: accept → vaccinate
sub-task → report, a Server-scope quest flag verified via qa_get_prop). Needs AiControl.AllowQaCommands=True
and a Russian client (--Client.Language russ) so dialog text matches the Russian answer keywords.
python Tools/AiControlMcp/tla_quest_runner.py --list
python Tools/AiControlMcp/tla_quest_runner.py --quest cassidy_letter --name QuestCassidy1 --register \
--require-exercised --report Workspace/cassidy.json
python Tools/AiControlMcp/tla_quest_runner.py --quest klam_vaccination --name QuestKlam1 --register \
--require-exercised --report Workspace/klam.json
The same runner can discover localized answer paths before a quest spec is authored. --trace-dialog
replays bounded paths against a visible NPC, reads the requested quest flag through the asynchronous
server-authoritative qa_get_prop round-trip, and emits the answers that increase it. Candidates are ranked by
stage advance and include the full visible node_path plus stable Russian keyword substrings:
python Tools/AiControlMcp/tla_quest_runner.py --trace-dialog --map arroyo \
--npc CassidyStage4 --dialog arroyo_cassidy --flag ArroyoCassidyLetter \
--npc-hex 82 113 --name TraceCass --register --trace-max-candidates 1 \
--report Build/_artifacts/trace-dialog/cassidy.json
Run the server with --ServerNetwork.InactivityDisconnectTime 0 for a long trace. Use a disposable QA
character: the runner restores the requested flag in finally, but arbitrary authored side effects such as
items, dialog cooldowns, and other properties cannot be rolled back generically. --setup-json accepts either
a JSON array or a path/@path to an array using the same prop / game_prop / item entries as quest specs.
The traversal is bounded by --trace-max-depth (12), --trace-max-paths (96),
--trace-max-candidates (8), and an internal --trace-max-seconds wall-clock budget (180). It uses
priority-guided replay, tolerates one-shot first-meeting roots and randomized greeting/exit text, retries a
missing or delayed qa_get_prop response under async load, and correlates delayed replies by request id.
Property setup/reset uses the correlated qa_prop_set acknowledgement and resends an unacknowledged write;
confirmed same-map teleports and talk_to actions are retried as well. First-time Debug
map loads get a 90-second observed transition window, configurable with --map-timeout; success still requires
observing the requested map.protoId. Reports expose truncated, time_limit_reached,
branch_errors, flag_restored, and restore_error explicitly.
A spec stage can carry a setup list — {"prop"|"game_prop"|"item": name, "value"|"count": n} — applied
after the teleport and before talking, to satisfy a giver’s prerequisite demands (an attribute like
Intellect, a CurrentHp, a GAME flag, or a required inventory item). The dialog navigator auto-advances
single-answer intros, never bails out on an exit-like answer while a fresh one remains, and remembers chosen
answers across re-opens so each re-open explores a new branch — which is what makes a topic-tree giver like
Mynoc reachable without hand-coding the exact path. Current specs use monotonic stage targets: a completed
character reports already_satisfied=true instead of wandering an obsolete dialog branch. Use
--require-exercised for regression runs that must verify at least one real state transition.
Verified end-to-end (2026-07-11)
register/logindrive the real TLA auth path; a character spawns andobservepopulateschosen/map/critters/mapItems/quests.move_to_hexmoves the chosen to the exact target hex;pick_item/talk_toauto-walk to their target;say,attack_entity,dialog_answerare accepted and processed; dialogs open and advance by answer index.- Dialog observation captures
dialogId/talkerId/ answer structure. A freshrepl1registration opened the ordinaryrepl_hubb_operdialog with readable speech and answer; an engine framebuffer capture also confirmed the composed dialog window renders correctly. ContentQuality verifies every reachable speech and answer in all 17repl_*.fodlgfiles is present and non-empty in both languages (excluding invisible pre-dialog routing node 1). environment_query pathcorrectly reports reachable vs unreachable target hexes (reachable/pathLength);obstacleslists blocked hexes — used by the playtest runner to pick reachable targets.qa_teleport_hexrepositions on the current map;qa_teleport_map arroyoreaches the realarroyovillage map with its quest NPCs. Exactvault_city/vcity_courtyardtargeting is verified through the split remote-call contract; an unknown proto returnsunknown_map_targetwithout reaching server validation.- After the dialog text-key fix, a real quest dialog is read and navigated end-to-end: the agent walked the
Savinelli caravan-escort dialog on
arroyo_bridgethrough 5 readable speeches, choosing meaningful answers. - A real quest is accepted with an observable state change: on the
arroyovillage map the agent talked to Cassidy, asked for work, read the offer (“отнеси письмо в Город-Убежище для Синди”), answered “Да, конечно”, andobservation.queststhen showsCritterProperty::ArroyoCassidyLetter = 1, with theletter_to_sindyitem added to inventory. (Caravan-escort quests, by contrast, are correctly time-gated — they only accept near departure.) - A full quest cycle completes end-to-end across two cities via teleport: after accepting the Cassidy
letter at Arroyo, the agent
qa_teleport_map vault_city/vcity_courtyardreaches Cindy (vc_cindyat hex65,55), reads her dialog (“От Кэссиди?! … Давай его скорей сюда!”), answers “Вот оно, держи.”, and the quest advances toCritterProperty::ArroyoCassidyLetter = 2with theletter_to_sindyitem consumed. (TheCreateLocationsandbox instance DOES spawn the map-placed quest NPCs; an earlier “missing Cindy” reading was a wrong-hex lookup. Afterqa_teleport_hexon a large map, nudge a couple of hexes and re-observeto let the client sync nearby critters.) - The whole cycle is reproducible through
tla_quest_runner.py --quest cassidy_letter --require-exercised(ok: true,transitions_verified: 2,ArroyoCassidyLetter0 → 1 → 2).qa_teleport_mapsetsAutoGarbage=falseon the instances it creates (and recreates a destroyed one) so a content location persists across runner stages/runs. - A second full quest cycle runs on a single giver:
tla_quest_runner.py --quest arroyo_mynoc_oil(ok: true,ArroyoMynocOil0 → 1 → 2) — accept “Я принесу тебе смазку” (gated onIntellect > 3, set via stagesetup) then hand in theoil_can(“У меня есть маслёнка”, item granted viasetup). This exercises the robust navigator (Mynoc’s accept lives several topic-tree hops deep behind a first-meeting intro). - A third and fourth full quest cycle broaden the shapes and towns:
den_smitty_robot(DenSmittyFixit1 → 2 → 3 → 4 — accept at Smitty, repair his Mr. Handy at the workbench gated onSkillRepair+ three items, report back) andklam_vaccination(KlamVaccination0 → 1 → 2 in Klamath — accept Hish’s syringe task, the three per-brahmin sub-task flags supplied viasetup, then report).KlamVaccinationis aServer-scope flag (notOwnerSync), so it is verified through theqa_get_propround-trip, not the client observation. - The QA-setup commands are verified:
qa_set_prop Experience 5000/qa_set_prop ArroyoCassidyLetter 1change the observed property;qa_give_item stimpak 3/qa_give_item letter_to_sindyadd the items;qa_get_prop KlamVaccinationreads back aServer-scope flag the observation can’t see. These unblock and verify quests with prerequisites: some givers gate their dialog on faction loyalty or a prior quest stage (Arroyo’s Todd needsDialog::CheckLoyality), which a fresh QA character lacks — set the prerequisite first, then run the quest. - Fixed an event-buffer flood:
OnInputLostfired every frame while the client window was unfocused (the normal state for an automated/headless client), pushinginput_lostuntil it evicted real events (command_completed,qa_prop_value,qa_prop_set) from the bounded event ring. It is now gated behindAiControl.CaptureRawInputEvents(off by default), so automation runs see a clean event stream. smoke_ai_control_mcp.py(static and live) andtla_mechanics_playtest.pypass.
Dialog trace seeds (2026-08-08)
--trace-dialog was verified against a fresh Russian client and the real CassidyStage4 NPC on arroyo.
The bounded replay handled the one-shot introduction, found the three-answer path through “work” and the letter
offer, and emitted Да, конечно. as the rank-1 candidate for ArroyoCassidyLetter (0 → 1). The JSON report
recorded 5 explored paths, 1 root rebase, no branch errors, the complete node path, and a confirmed restore to
the original value 0.
The same live workflow covers the real Arroyo Mynoc placement (protoId=EnclaveGuard,
dialogId=arroyo_mynoc) with IntellectBase=6 supplied through the acknowledged setup path. Quest-guided
ordering found Я заметил... Твоя броня, она несколько поржавела... → Я принесу тебе смазку... in 2 explored
paths and 13.7 seconds, observed ArroyoMynocOil 0 → 1, reported no branch errors, and confirmed restore to 0.
The Den seed is now live-verified across all four authored stages. The real HomesteaderMale / den_smitty
path accepted the job (DenSmittyFixit 0 → 1); MrHandy / den_mr_handy exposed the Repair-85 diagnosis
(1 → 2) and accepted pump_parts + oil_can + super_tool_kit (2 → 3); Smitty then accepted the report
(3 → 4). Every trace found a ranked candidate without branch errors and restored the original flag value 0.
Reports are Build/_artifacts/trace-dialog/smitty-{accept,examine,repair,report}-live.json. This validates 3 of
the 7 Stage-0 trace quests.
The fourth seed uses the real Klamath MasterTrader / klam_hish at hex 82,132. With IntellectBase=6,
bounded replay found the five-answer path from looking for work through the name joke and vaccination offer to
Хорошо, не волнуйся. Я не промахнусь., observed KlamVaccination 0 → 1 in 5 explored paths and 20.97 seconds,
reported no branch errors, and restored 0. The report is
Build/_artifacts/trace-dialog/klam-hish-accept-live.json.
The three fresh Arroyo traces complete the Stage-0 criterion. The normal Todd talk path first reported
no_visible_start_speech despite an alive, talk-enabled, adjacent and mutually visible NPC. The project dialog
parser was retaining the @ sigil and full Content::* qualifier in script arguments; normalizing those values
restored the authored loyalty demand, after which ArroyoProofOfDeath advanced 0 → 1 without a mechanic bypass.
Todd’s follow-up offer advanced ArroyoLetterToLinnett 0 → 1 with ArroyoProofOfDeath=3 as its authored
prerequisite. Finally, stable-slot replay handled Mynoc’s randomized @@ wording and exposed an unconditional
minimum-group guard that contradicted the solo stage-1 dialog branch. Restricting that guard to stage 2 produced
the full 13-answer path and ArroyoMynocDefence 0 → 1. All three runs had no branch errors and restored 0; reports
are Build/_artifacts/trace-dialog/arroyo-{todd-proof,letter-linnett,mynoc-defence}-live.json. Stage-0 trace
coverage is 7/7.
The next Den trace corrected a stale diagnosis and completed DenMomSlut end to end. The parser already inferred
DenVirginIsAway as a Game property; the hidden answer came from comparing integer property value "0" with
textual any value "false". Dialog property booleans are now normalized to 0/1, restoring all 51 authored
boolean demand/results. Live replay advanced 0 → 1 at Mom in 27 paths, 1 → 2 at Virginia in 2 paths (executing
DenVirgin::GoAway), and 2 → 3 at Mom in 1 path. All runs restored the original flag and had no branch/log errors.
Reports are Build/_artifacts/trace-dialog/den-{mom-slut-accept,virginia-away,mom-slut-report}-live.json.
Mechanics beyond dialog (verified 2026-06-21)
A battery of non-dialog gameplay mechanics was driven end-to-end on a real client, each with an observable
state change in chosen / inventory / mapItems:
- Skill use (self first-aid).
qa_set_prop SkillFirstAid 200→qa_set_prop CurrentHp 6→use_skill SkillFirstAid(no target ⇒ applied to self);chosen.currentHpwent 6 → 33. - Item use / healing.
qa_set_prop CurrentHp 5→qa_give_item stimpak→use_item <stimpak id>;chosen.currentHpwent 5 → 18 (the stimpak healed the character). - Combat / kill.
qa_give_item super_sledge→move_item <weapon id>to slotCritterItemSlot::Main(= 1;Inventory0,Main1,Secondary2,Armor3) to equip it → locate amob_brahmin(passive, spawns on the Arroyo maps) →attack_entity(mode0), re-issued each AP cycle while polling the target’sisAlive; the brahmin died andchosen.experiencewent 0 → 80. Boost damage first withqa_set_prop SkillMeleeWeapons 250/qa_set_prop StrengthBase 10. Attack maps toCurPlayer.ServerCall.Attack(targetId, mode). - Weapon reload.
qa_give_item _10mm_pistol+qa_give_item _10mm_ap 30→move_itemthe pistol to theMainslot →reload <pistol id>(accepted). - Equip / inventory move.
move_item <id> <slot>— verified round-tripping a_10mm_pistolthroughMain(1) →Secondary(2) →Inventory(0) with the observedinventory[].slotmatching each request. - Drop.
drop_item <id>puts the item on the ground (it appears inmapItems). (Ground re-pickup was timing-flaky in the batch.) - Crafting (FixBoy).
qa_set_prop SkillOutdoorsman 120→qa_give_item brahmin_skin 3/pocket_lint/rope/knife→craft 1(recipe id 1 =leather_armor); the armour appeared ininventoryand the resources were consumed (brahmin_skin3 → 1). The server checks the recipe’s skill/material/tool demands and only crafts when they pass. - Barter. Teleport next to a merchant (Arroyo
arroyo_cassidyat(82,113)),talk_to, then pick the barter answer (“Может, поторгуем?”);chosen’s activescreenbecomesGuiScreen::Barter. The barter answer is the engineAnswer Barterlink, which routes throughBarter::StartNpcBarter. - Loot / container grid. Kill a critter, then
loot_critter <id>opensGuiScreen::PickUpandoperate_container(intArg=3= take + all) pulls its inventory. Verified on amob_brahmincorpse. - Global-map travel does NOT work from a sandbox map (and corrupts transfer state).
qa_teleport_global(TransferToGlobal) is a no-op from theqa_teleport_mapsandbox instances (they carry no valid worldmap position), and after calling it the character’s hex transfers stop applying (a stuck transfer state) — a fresh character recovers. Real global-map travel needs canonical world/town state, not aCreateLocationsandbox; treatqa_teleport_globalas unsupported there. Sleeping/rest is likewise not a general mechanic in TLA (only mining has aSTR_NEED_RESTgate). - Sneak is NOT implemented in TLA.
toggle_sneaknever changeschosen.inSneakMode, because the game itself stubs it: theTla::ChosenSneakaction body has itsGame.CustomCall(...)commented out (ChosenActions.fos) and theUseSneakRemoteCall is disabled (commented///not///@, body inside/* */). The bridge now returnssneak_not_implemented_in_gameinstead of silently queuing a no-op — a game-content gap, not a bridge defect.
Adding more quests (what still needs care)
The upgraded navigator (auto-advance intros, strong exit-avoidance, seen-tracking across re-opens) handles
both a simple giver (Cassidy) and a deep topic-tree giver (Mynoc). What remains per-quest is mostly getting
the right answers to appear, via stage setup and the right client language:
- Localization. The default client runs
Client.Language = engl, so any NPC that has an English translation shows English answers and the Russianpreferkeywords never match. Run the client with--Client.Language russ(the specs are written from the.fodlg[Text russ]source). - Prerequisite demands. A quest answer can be hidden behind a critter property (
Intellect,CurrentHpfor Doc’sIsToHeal), an inventory item (Mynoc’soil_can), or a GAME flag — supply them with stagesetup(qa_set_propfor critter props,qa_set_game_propfor game props,qa_give_itemfor items). - Non-quest flags. Some “quest-like” props are not in
CritterPropertyGroup::Quests, so they never appear inobservation.quests(ArroyoArroyoDocHealingis aServerflag,Max = 2). Pick targets that areGroup = Questsif you want to assert progress from the observation. - Resolved parser migration defects. Todd’s
@!loyalty argument had been hashed with the sigil after the project dialog parser moved away fromResolveGenericValue; the parser now restores the legacy script-argument spelling. Den Mom’s hidden “Virginia” answer was not a Game/Player scope mismatch: the parser inferred the Game owner correctly, but property booleanfalseremained text instead of integer0. Property booleans are now normalized to their integer storage spelling, and both normal talk paths are live-green.
The QUESTS dict ships three verified flows plus a NOTE documenting these knobs:
cassidy_letter— accept at Arroyo, deliver across town to Vault City (ArroyoCassidyLetter0 → 1 → 2).arroyo_mynoc_oil— accept + turn-in at one giver, gated byIntellectand anoil_canitem (ArroyoMynocOil0 → 1 → 2).den_smitty_robot— four stages in one town: accept at Smitty → examine the Mr. Handy robot (needsSkillRepair > 59) → repair it (needspump_parts+oil_can+super_tool_kit) → report back (DenSmittyFixit1 → 2 → 3 → 4). Shows a quest gated by both a skill and inventory items, all supplied via stagesetup.
Adding a quest is: pick a Group = Quests target, give each stage its setup, and run once against a Russian
client to confirm.
Fixed: empty dialog text (key mismatch)
Surfacing dialog text/answers via the bridge exposed a real game bug: all dialog speech/answer strings
resolved empty. Root cause — a text-pack key mismatch between the baker and the runtime lookup. The
DialogBaker stores each dialog text under a 4-part key FromParts("Dialogs", <dialogName>, <textKey>, ...)
(it includes the dialog name), but Dialogs.fos looked it up with only the text key
(TextPackKey(TextPackName::Dialogs, "" + textId)), so the keys never matched and Game.GetText returned
empty for every dialog (a normal player saw the same).
Fix (in Scripts/Dialogs.fos): build the lookup with the dialog id as the first key part —
TextPackKey(TextPackName::Dialogs, dialogId, textId) in ReceiveDialogContext (screen text + answers) and
the answerless Say/Info path. Verified: arroyo_laumer now reads its real Russian quest dialog and the
agent navigates it answer-by-answer.
Fixed: empty NPC floating text (numbered dialog strings)
The same class of bug hit the numbered dialog strings — the lines NPCs say on their head in combat/ambient
(Messaging::Say/SayOnHead/Info), keyed by MsgStr::DialogTextId(dialogId, num). DialogBaker stores
these under [Dialogs, <dialogName>, <100000000 + num>] (the authored key base 100000000 is shared by every
dialog; the dialog name disambiguates), but the call sites looked them up as
TextPackKey(TextPackName::Dialogs, "" + MsgStr::DialogTextId(dialogId, num)), which is wrong twice: it
omits the dialog name, and DialogTextId = dialogId.uhash + 12000 + num no longer reproduces the baked key
(the 64-bit uhash overflows to a garbage number). So all of these floating lines were empty.
Fix: a single helper MsgStr::DialogTextKey(dialogId, num) →
TextPackKey(TextPackName::Dialogs, dialogId, "" + (100000000 + num)), and the 101 call sites across 26
files (TextPackKey(TextPackName::Dialogs, "" + (MsgStr::DialogTextId(EXPR, N))) → DialogTextKey(EXPR, N))
were migrated to it. Verified through the qa_get_text debug command (resolves MsgStr::DialogTextKey for a
given dialog id + number): klam_aldo 1/2/3 (“Не могу к доске подобраться…”, …), all_poker 0 (“Ваши карты:”),
bh_phil 200 — all now return their real text.
Fixed: empty dialog text in @text format tags (poker / roulette / banker / NPC names)
The other consumer of the numbered dialog strings is the @text Dialogs <id>@ format tag — built
server-side (poker card names, roulette, the replication-banker “spy” descriptors) and resolved client-side by
Game.FormatTags so each player sees their own language. The tag carried a single id
(MsgStr::DialogTextId(...)), so — like the Say/Info path — it omitted the dialog name and used the wrong
number; all of it rendered empty. NPC names via @text Dialogs <MsgStr::NpcNameDlgTextId(dlg)>@ were broken
the same way (the name is stored under the string key "Name").
Fix, in two parts:
- Engine (
SourceExt/ClientExtension.cpp::FormatTags): the@texttag now also accepts a three-token form@text <pack> <subkey> <key>@→FromPack(pack, subkey, key)(a second key). The two-token form is unchanged, so every existing tag keeps its behaviour. - Script: two helpers —
MsgStr::DialogTextTag(dialogId, num)→@text Dialogs <dialogId> <100000000+num>@andMsgStr::NpcNameTag(dialogId)→@text Dialogs <dialogId> Name@— and 157 sites migrated to them (146 numbered-text acrossDialog.fos/Poker.fos/Roulette.fos, 11 NPC-name across 8 files).
Verified end-to-end with the qa_format_tags debug command (runs a raw string through Game.FormatTags):
@text Dialogs klam_aldo 100000001@ → “Не могу к доске подобраться…”, @text Dialogs all_raider Name@ →
“Рейдер”, @text Dialogs all_poker 100000000@ → “Ваши карты:”, and the same tag embedded mid-sentence
resolves in place. (Requires a native TLA_Client rebuild for the FormatTags change.)
Fixed: empty proto-name @text tags (critter / item / location names)
@text Critters/Item/Locations <id>@ (NPC, item and location names in a handful of messages) were broken
differently. The engine ProtoTextBaker stores a proto’s localized $Text <lang> name under
[<pack>, <protoId>] — i.e. the proto id string itself is the key, and the pack names are Critters,
Items (plural!) and Locations. The call sites instead passed a number (pid.uhash via
NpcProtoNameTextId/ItemNameTextId/LocNameTextId) and, for items, the wrong pack name Item (singular) —
so all of it missed and rendered empty.
Fix: helpers MsgStr::NpcProtoNameTag(pid) → @text Critters <pid>@, MsgStr::ItemNameTag(item) /
ProtoItemNameTag(pid) → @text Items <pid>@, MsgStr::LocNameTag(pid) → @text Locations <pid>@, with the
15 sites (across Behemoth/Caravan/FighterQuest/GameEventCaches/NcrPostman/Repairer/Resources/
Traveller) migrated to them. These are plain two-token tags (no engine change needed beyond the three-token
support already added). Verified via qa_format_tags: @text Critters Algernon@ → “Алжернон”,
@text Items _10mm_ap@ → “10 мм ББ”, @text Locations klamath@ → “Кламат”, and embedded in a sentence.
Roadmap (next)
- A content-driven quest runner on top of
qa_teleport_map(accept → travel → turn in) covering more quests; the Cassidy letter cycle above is the reference flow. - The proto-text long tail is done (full key scheme verified — name
[Pack, protoId], desc[Pack, protoId, "Desc"], numbered[Pack, protoId, "<rawN>"]). Item names/descriptions in look/examine (ItemNameKey/ItemInfoKey, 9 sites inClientMain.fos); all 16 worldmap/crafting GUI sites — location name/desc/map/entrance/picture + crafting item names (LocNameKey/LocInfoKey/LocPicKey/LocLabelPicKey/LocMapNameKey/LocEntranceNameKey/LocEntrancePicx|yKey/ProtoItemNameKey) edited in the.foguisource and regenerated intoGuiScreens.fos. The old single-key resolvers (GetItemText/GetLocationText/GetCritterText/HasLocationText) are now unused and were removed. Also fixed theAddDialogStr/STR-const system: the replication-bank terminal + roulette messages were empty becauseAddDialogStr(int msg)did a single-key lookup of aDialogTextIdnumber — the RemoteCall is nowAddDialogStr(hstring dialogId, int strNum, …)→DialogTextKey, the STR constants became plain string numbers, andDialogBarman.fos’s malformedtext Dialogs/Locationstags (missing the leading@) were corrected. Verified (@text Dialogs repl_terminal 100000000@→ “Ошибка. Пожалуйста обратитесь…”). The onlyDialogTextIdremnants left are vestigial (commented-out poker// Todo: SayMsgconsts + one Caravan debug log). - Global-map observation + travel/enter — for canonical world state and travel-only mechanics (random
encounters, party travel). Quest accept/turn-in itself already works via
qa_teleport_map(the sandbox instance spawns the map-placed quest NPCs). - Resolved quest titles/objective text in
quests(and faction, skills/abilities observation). - Richer screen/affordance surfaces beyond the current barter, container/item metadata, and nine-window contextual GUI runner (registration customization and remaining mechanic-owned screens).
- Content-driven playtest runners per quest/mechanic on top of
qa_teleport_map; optionally adapt more of the ported advisory/agent-runtime adapter layer.