Control the AC27 Editor from Claude Code via MCP tools. Create, read, modify, and delete flights in the currently-open .acl schedule file. Supports English and Chinese (中文) interaction.
Install
npx skillscat add ericpzh/ac27leveleditor/ac27-editor-mcp Install via the SkillsCat registry.
AC27 Editor MCP — Skill
1. What This Skill Enables
Control the AC27 Editor from Claude Code. Create, read, modify, and delete flights in the currently-open .acl schedule file. The user is responsible for opening and saving files via the editor UI — the MCP operates on whatever level is currently open.
2. Prerequisites
- Editor must be running (
npm startor double-click the app). The API server auto-starts on127.0.0.1:31415. - A level must be open in the editor (user loads it manually via the UI).
- This skill file must be installed so Claude Code understands the domain. Download from GitHub and place at
~/.claude/skills/ac27-editor-mcp/SKILL.md. mcp/bridge.jsmust be accessible on disk. Clone the repo or download from GitHub.- Node.js required to run the bridge.
- Claude Code configured (see below).
Dev setup (.mcp.json at project root — auto-detected):
{
"mcpServers": {
"ac27-editor": {
"command": "node",
"args": ["mcp/bridge.js"]
}
}
}Packaged app setup (.mcp.json next to the .exe, with mcp/bridge.js in the same folder):
{
"mcpServers": {
"ac27-editor": {
"command": "node",
"args": ["./mcp/bridge.js"]
}
}
}3. Chinese Language Support (中文支持)
Users may interact in Chinese. The editor has full bilingual data — this skill teaches you how to use it.
Airline Name Resolution (航空公司名称映射)
When a user says "国航" or "Air China", resolve to the 3-letter ICAO code.
Chinese Domestic Airlines (中国国内航司)
| Chinese Name | English Name | ICAO | Common Aliases / Short Names |
|---|---|---|---|
| 中国国际航空 | Air China | CCA | 国航 |
| 中国东方航空 | China Eastern Airlines | CES | 东航 |
| 中国南方航空 | China Southern Airlines | CSN | 南航 |
| 海南航空 | Hainan Airlines | CHH | 海航 |
| 国泰航空 | Cathay Pacific | CPA | 国泰 |
| 厦门航空 | Xiamen Airlines | CXA | 厦航 |
| 四川航空 | Sichuan Airlines | CSC | 川航 |
| 深圳航空 | Shenzhen Airlines | CSZ | 深航 |
| 山东航空 | Shandong Airlines | CDG | 山航 |
| 春秋航空 | Spring Airlines | CQH | 春秋 |
| 吉祥航空 | Juneyao Air | DKH | 吉祥 |
| 上海航空 | Shanghai Airlines | CSH | 上航 |
| 成都航空 | Chengdu Airlines | UEA | 成都 |
| 西藏航空 | Tibet Airlines | TBA | 西藏 |
Major International Airlines (主要国际航司)
| Chinese Name | English Name | ICAO | Common Chinese Short Names |
|---|---|---|---|
| 美国航空 | American Airlines | AAL | 美航 |
| 联合航空 | United Airlines | UAL | 美联航 |
| 达美航空 | Delta Air Lines | DAL | 达美 |
| 英国航空 | British Airways | BAW | 英航 |
| 法国航空 | Air France | AFR | 法航 |
| 汉莎航空 | Lufthansa | DLH | 汉莎 / 德航 |
| 阿联酋航空 | Emirates | UAE | 阿联酋 / 土豪航 |
| 全日本空输 | All Nippon Airways | ANA | 全日空 |
| 日本航空 | Japan Airlines | JAL | 日航 |
| 新加坡航空 | Singapore Airlines | SIA | 新航 / 星航 |
| 大韩航空 | Korean Air | KAL | 大韩 |
| 长荣航空 | EVA Air | EVA | 长荣 |
| 中华航空 | China Airlines | CAL | 华航 |
| 卡塔尔航空 | Qatar Airways | QTR | 卡航 |
| 土耳其航空 | Turkish Airlines | THY | 土航 |
| 西南航空 | Southwest Airlines | SWA | 西南 |
| 边疆航空 | Frontier Airlines | FFT | 边疆 |
| 夏威夷航空 | Hawaiian Airlines | HAL | 夏威夷 |
| 加拿大航空 | Air Canada | ACA | 加航 |
| 澳洲航空 | Qantas | QFA | 澳航 |
| 荷兰皇家航空 | KLM | KLM | 荷航 |
| 瑞士航空 | Swiss | SWR | 瑞航 |
How to resolve: Call get_airport_info → check constraints.airlineCode (the codes known for this airport). Match user intent to a code using the name↔code table above: "国航" → "Air China" → "CCA". If the user uses a short name not listed above, ask for or infer the 3-letter code (the same name→code map lives in src/utils/constants/airlines.js as AIRLINE_CODE_MAP).
Chinese Field Name Mapping (中文字段名映射)
When a user uses Chinese field names, translate to the API field key:
| Chinese | API Field | Chinese | API Field | |
|---|---|---|---|---|
| 呼号 | CallSign | 航司 | AirlineName | |
| 出发 | DepartureAirport | 机型 | AircraftType | |
| 到达 | ArrivalAirport | 进场程序 | Airway | |
| 停机位 | Stand | 注册号 | Registration | |
| 跑道 | Runway | 语音 | Voice | |
| 推出 | OffBlockTime | 语言 | Language | |
| 起飞 | TakeoffTime | 航司代码 | AirlineCode | |
| 落地 | LandingTime | 航班号 | FlightNum | |
| 入位 | InBlockTime |
Chinese Phrase Translation Rules
- "把所有国航的航班改成01跑道" →
modify_flights({match:{airline:"CCA"}, updates:{Runway:"01"}}) - "创建5个东航的出发航班" → build 5 flight objects with CallSign prefix "CES", type=departure
- "把停机位G5的航班注册号改成B-1234" →
modify_flights({match:{stand:"G5"}, updates:{Registration:"B-1234"}}) - "删除所有14:00前的国泰到达" →
get_flights({airline:"CPA", type:"arrival", timeBefore:"14:00"})thendelete_flights - "出发" / "离港" = departure (has OffBlockTime + TakeoffTime)
- "到达" / "进港" = arrival (has LandingTime + InBlockTime)
- Time: "10点" → "10:00", "10点30分" → "10:30", "下午2点" → "14:00"
4. Flight Anatomy
- Arrival vs Departure: Arrivals have LandingTime + InBlockTime + DepartureAirport. Departures have OffBlockTime + TakeoffTime + ArrivalAirport.
- Callsign format: 3-letter ICAO airline code + flight number (e.g.,
CCA1234). - The 15 fields: CallSign, DepartureAirport, ArrivalAirport, Stand, Runway, OffBlockTime, TakeoffTime, LandingTime, InBlockTime, AirlineName, AircraftType, Airway, Registration, Voice, Language.
- AirlineName stores the 3-letter airline code, NOT a display name — the game format uses codes (e.g.
"CCA","UAL").create_flightsfills it from the callsign prefix when left empty, so it can be omitted safely. - Departure/arrival is derived server-side:
create_flightssets an internalisDepartureflag fromOffBlockTimepresence; the saved ACL leg (InitialArrivalvsInitialDeparture) follows it.get_flightsrows include thisisDepartureboolean. - Time format: HH:MM:SS (or HH:MM shorthand). Times are wall-clock times within the scenario config range.
- Cascade rules (applied server-side on modify): Changing AirlineCode rebuilds CallSign, resets Registration to first valid (the aircraft type is independent and preserved), and syncs AirlineName to the new code. Changing FlightNum rebuilds CallSign. Changing Runway resets Airway to first valid STAR.
- Registration format: Country prefix + hyphen + alphanumeric (e.g.,
B-1234,N123AB).
5. Validation Rules — How to Create Valid Flights
The server WILL reject invalid requests with a 422 error containing error.details. Follow these rules to avoid rejection.
Rule 1: Airline code must be known. Check constraints.airlineCode from get_airport_info. If the code isn't listed, the flight is rejected.
Rule 2: Flight number must be canonical (if known). Check constraints.flightNumbers[airlineCode]. If the airline has a list, use a number from it. If no list, any numeric string is accepted.
Rule 3: Aircraft type is independent of the airline. Choose any type from constraints.aircraftTypes (the game's full profiled type pool) regardless of the callsign's airline. constraints.airlineAircraftCompat is informational only.
Rule 4: Airway/STAR must be compatible with runway (arrivals only). Check constraints.runwayStarCompat[runway]. If the runway has a list, the airway MUST be from it.
Rule 5: Registration must be valid for (airline, aircraft) pair. Check constraints.registrationsByPair['AAL|A320']. If the pair has a list, the registration MUST be from it. Also: same registration on 2+ departures OR 2+ arrivals = rejected.
Rule 6: Times must be within config range. Check configTimeRange. All times must fall within this window. LandingTime < InBlockTime. OffBlockTime < TakeoffTime.
Rule 7: Stand conflicts are checked. Two departures on the same stand always conflict. A departure and arrival on the same stand conflict when OffBlockTime >= LandingTime.
Rule 8: How to construct valid flight objects (14-step procedure):
- Call
get_airport_info. CheckcacheReady— if false, warn user. - Choose an airline code from
constraints.airlineCode. - Choose a flight number from
constraints.flightNumbers[airlineCode]. Combine intoCallSign = airlineCode + flightNumber. - Choose any aircraft type from
constraints.aircraftTypes(airline-independent). - Choose a runway from
constraints.flatLists.Runway. - Choose a stand from
constraints.flatLists.Stand. - For arrivals: choose an airway from
constraints.runwayStarCompat[runway]. For departures: leave Airway empty. - Choose a registration from
constraints.registrationsByPair['airlineCode|aircraftType']. - Set AirlineName to the 3-letter code itself (e.g.
"CCA") — the game stores codes, not display names. May be left empty; the server fills it from the callsign prefix. - Set times within
configTimeRange. Arrivals: LandingTime < InBlockTime. Departures: OffBlockTime < TakeoffTime. - Set DepartureAirport (arrivals) or ArrivalAirport (departures) to the current airport ICAO.
- Set Voice and Language from
constraints.flatLists. Follow the editor's region convention:zhif the airport ICAO starts withZ(China), elseen; Voice — pick randomly from the airport's pool (already region-appropriate:CN-*voices at Z airports, e.g.Yeagerat US airports). - Build the complete 15-field object and pass to
create_flights({flights: [obj, ...]}). - If 422: read
error.details, fix the specific fields, retry.
Rule 9: Server-side validation is the backstop. If you miss a constraint, the server rejects with error.details containing the valid options for the rejected fields. You don't need to re-fetch get_airport_info to recover.
6. Tool Reference
get_editor_status
No parameters. Returns: editorReady, currentPath, currentAirport, flightCount, arrivalCount, departureCount, configStartTime, configEndTime, isDemo, modified, hasTimelines.
get_airport_info
No parameters. Returns: cacheReady, currentAirport, configTimeRange, and constraints with all validation maps (flatLists, airlineCode, flightNumbers, aircraftTypes, airlineAircraftCompat, runwayStarCompat, registrationsByPair, timeRules, standRules, registrationRules). Call this before any create/modify.
create_flights
{flights: [{CallSign, DepartureAirport, ArrivalAirport, Stand, Runway, OffBlockTime, TakeoffTime, LandingTime, InBlockTime, AirlineName, AircraftType, Airway, Registration, Voice, Language}]}. All 15 fields required per flight. Returns {success, created} or 422 with error.details. Server-side enrichment: isDeparture is derived from OffBlockTime presence (drives which leg the save writes); AirlineName defaults to CallSign.substring(0, 3) when empty.
get_flights
{type?, airline?, callsign?, stand?, runway?, aircraftType?, timeAfter?, timeBefore?, limit?, offset?}. Returns {success, flights, total}. Rows carry the 15 fields plus an isDeparture boolean (internal flag the save uses to choose the leg).
modify_flights
{match: {callsigns?, callsign?, airline?, type?, stand?, runway?, aircraftType?}, updates: {Stand?, Runway?, OffBlockTime?, ...}}. Server applies cascade logic. Returns {success, matched, modified} or 422.
delete_flights
{match: {callsigns?, callsign?, airline?, type?, stand?, runway?, aircraftType?}}. Returns {success, deleted}.
get_validation_issues
No parameters. Returns {success, issues, duplicateCallsigns, standConflicts, duplicateRegistrations}.
send_voice_command
{transcript: string}. Parses a spoken-style sentence against the live aircraft list (same pipeline as the PTT mic — parseVoiceTranscript; typed input gets the parser's fuzzy matching for free — "turn lef heading 360" parses, and the two semantic inversions ascend→descend / decrease→increase stay excluded). Waypoint target set for fly direct to X comes from the airport cache (approachData.airwayNodes — same source as the AirMap's Waypoints layer; the [VOICE-PARSE] log line reports wps=N). Dispatches the patch frames to the game in real time. Requires the game + BepInEx plugin running so UDP telemetry exists. Returns {success, callsign, renderedLine, sent: [{type, label}], notices, aircraft}. Selection-only (bare callsign) → success:true, sent:[]. Fails with success:false when no aircraft matches, or the aircraft is not on the approach channel (controlSeat 5). Each parse also prints a [VOICE-PARSE] line to the editor's main-process log (npm start terminal). Example transcripts: "CSC6918: climb and maintain 9000, reduce speed to 180 knots", "AAL218: fly direct to BELTT" (language auto-detected, en/zh).
7. Workflow Rules
- Always check status first — call
get_editor_statusat the start of any task. IfeditorReadyis false, tell the user to open a level. - Get airport info before creating/modifying — call
get_airport_info. Follow the 14-step procedure in Rule 8. - Verify after modifying — call
get_flightsafter changes to confirm the result. - Validate after changes — call
get_validation_issuesso the user knows about issues before saving. - Handle 422 errors gracefully — read
error.details, fix fields, retry. The error tells you exactly what to fix. - Remind user to save — the MCP cannot save files. Remind the user to click Save in the editor UI.
- Handle missing data — if
cacheReadyis false or constraint maps are empty, warn the user that validation may be limited.
8. Composition Examples
English Examples
Example A: "Create 10 AAL departures, 1 min apart, randomize aircraft"
1. get_editor_status → confirm level loaded, note currentAirport
2. get_airport_info → get constraint map (aircraft type pool, flight numbers, registrations)
3. Construct 10 flight objects (LLM internally):
Flight 1: { CallSign:"AAL1001", DepartureAirport:"", ArrivalAirport:"KJFK",
Stand:"G1", Runway:"04L", OffBlockTime:"10:00:00", TakeoffTime:"10:05:00",
LandingTime:"", InBlockTime:"", AirlineName:"AAL",
AircraftType:"A320", Airway:"", Registration:"N123AB",
Voice:"en-US-1", Language:"en" }
... (10 flights, incrementing times, aircraft from the type pool, reg from registrationsByPair)
4. create_flights({flights: [f1, f2, ..., f10]})
→ If 422: read error.details, fix, retry
5. get_flights({airline:"AAL", type:"departure", limit:20}) → show user
6. get_validation_issues → report issues
7. Remind user: "10 flights created. Click Save when ready."Example B: "Change all CCA flights to use runway 01"
1. get_editor_status
2. get_airport_info → check runwayStarCompat["01"]
3. modify_flights({match:{airline:"CCA"}, updates:{Runway:"01"}})
→ server cascades Airway to first valid STAR for runway 01
4. get_flights({airline:"CCA", limit:200}) → verify
5. Remind user to saveExample C: "Delete all JBU arrivals before 14:00"
1. get_editor_status
2. get_flights({airline:"JBU", type:"arrival", timeBefore:"14:00"})
→ show user, confirm
3. delete_flights({match:{callsigns:[...]}})
4. get_editor_status → confirm new count, remind to saveChinese Examples (中文示例)
示例 D: "创建10个国航出发航班,10点开始每隔1分钟一个,机型随机"
1. get_editor_status → 确认已加载 level,currentAirport = "ZSJN"
2. get_airport_info → 获取约束:
- airlineCode 包含 "CCA"
- flightNumbers["CCA"] = ["1501", "1502", ...]
- airlineAircraftCompat["CCA"] = ["A320", "B738", "B772"]
- configTimeRange = { start: "06:00", end: "22:00" }
3. 构造10个完整 flight 对象:
Flight 1: { CallSign:"CCA1501", DepartureAirport:"", ArrivalAirport:"ZSJN",
Stand:"G1", Runway:"01", OffBlockTime:"10:00:00", TakeoffTime:"10:05:00",
LandingTime:"", InBlockTime:"", AirlineName:"CCA",
AircraftType:"A320", Airway:"", Registration:"B-1234",
Voice:"zh-CN-1", Language:"zh" }
... (LLM 生成10个)
4. create_flights({flights: [f1, f2, ..., f10]})
5. get_flights({airline:"CCA", type:"departure", limit:20})
6. get_validation_issues
7. 提醒用户:"已创建10个航班,请在编辑器中点击保存"示例 E: "把所有国航航班改成01跑道"
1. get_editor_status
2. get_airport_info → runwayStarCompat["01"]
3. modify_flights({match:{airline:"CCA"}, updates:{Runway:"01"}})
4. get_flights({airline:"CCA"}) → 验证
5. 提醒保存示例 F: "删除14:00前所有日航到达"
1. get_editor_status
2. get_flights({airline:"JAL", type:"arrival", timeBefore:"14:00"}) → 展示并确认
3. delete_flights({match:{callsigns:[...]}})
4. get_editor_status → 确认9. Error Recovery
get_editor_statusreturnseditorReady: false→ tell user to open a level in the editor first (use Chinese if the user is speaking Chinese)get_airport_inforeturnscacheReady: false→ warn user the airport cache may not be readycreate_flightsormodify_flightsreturns 422 witherror.details→ read each detail, fix the specific fields, retry. Report the error in the user's language.- Airport constraint maps are empty → validation is best-effort; warn user but proceed
get_airport_infofails entirely → MCP cannot reach the editor; checknpm start -- --api-port=31415- User speaks Chinese but error uses English keys → translate field names for the user (e.g., "Stand 'G99' is not valid" → "停机位 'G99' 无效")
- User uses a Chinese airline name not in the mapping → resolve to a 3-letter code via the airline table in this skill (or
AIRLINE_CODE_MAPinsrc/utils/constants/airlines.js); if unsure, ask the user for the 3-letter code. Note:constraints.flatLists.AirlineNameis not populated — the name↔code mapping lives in the skill/constants, not inget_airport_info
10. Ground/Air Painter (unified)
Edit the level's static scenery (taxiways, runways, areas, stands) and airways (FIX waypoints + procedures) via the dedicated Ground/Air Painter window. The graph is id-free ({nodes, segments, runways, areas, stands, airwayNodes, procedures}); a meta side-table carries each object's original $k/OsmId/$id so survivors keep their ids at save. The mode toggle (ground ↔ air) is groundPainterMode in the store.
First turn on any ground/air task: get_editor_status → get_ground_painter_state.
10.1 Tool Coverage (mirrors the painter toolbar — ground + air)
| Toolbar Tool | MCP Tool | Notes |
|---|---|---|
| Air/Ground view toggle (toolbar, right side) | `set_ground_painter_mode({mode:'ground' | 'air'})` |
Taxiway Line D (straight) |
create_taxiway_lines({lines:[{a:{x,z}, b:{x,z}, name?, flags?}]}) |
Deduplicates nodes by coordinate (1e-6), validates distinct endpoints, pushes paired groundPainterHistory/groundPainterMetaHistory so undo restores correctly. flags defaults to 2 (wider). |
Taxiway Curve / Fillet F (rounding) |
create_taxiway_fillet({segA, segB, radius?}) |
Rounds the corner between two straight segments. segA/segB are segment indices (0..segments.length-1); radius 0.5..5.0 GU (default 2.0). Handles both connected (shared node) and virtual (disconnected) cases — virtual is additive (no deletions), connected truncates legs and inserts arc. Validates straight-only, angle 5..175°, parallel guard, degenerate-leg guard (collapsed leg/arc → rejected), pre-mutation same-anchor check, and ghost-ref repair. |
Runway R |
create_runways({runways:[{a:{x,z}, b:{x,z}}]}) |
Each runway is a runwayLine a→b pair. Names are derived from heading (e.g. 01/19) with suffix collision handling (L/R then C against taken physicalName/end names — avoids duplicate $k: runway:<name> → "found 0 named runways"), width 0.50, and a collinear pavement strip (Flags=4, name = physical name, 0.6 GU overhang) is auto-synthesized and coupled via meta.runwayPavement — same as GroundPainter.jsx:commitRunway. Returns created:[{names, physicalName}]. |
Area / Polygon G |
`create_areas({areas:[{areaType:0 | 1 |
Stand H (plane) |
create_stands({stands:[{x,z, heading:1..360}]}) |
Nose-anchored; creates nose+tail nodes (STAND_LENGTH=0.63) and a heading in 1..360 (0/unset → 360/north via _normalizeHeading). Mirrors GroundPainter.jsx:standHeadingPlacement. |
Airway Node N (air) |
create_airway_nodes({nodes:[{x,z, name?}]}) |
Air FIX waypoint. Creates a new airway node per entry; optional name defaults to FIX<n>; validates finite x/z; pushes paired history (meta airwayNodeOrigPk + null). |
Airway Procedure C (air) |
create_airway_procedures({procedures:[{name, routeType:0..3, runwayName, airwayNodeIdxs:[≥2]}]}) |
Chains existing airway nodes into a procedure (STAR 0 / APP 1 / SID 2 / MISS 3). Validates runway exists, idx range, no consecutive dup, duplicate (name,runway,routeType) rejected. Pushes paired history (meta airwaySegOrigPk). |
Air Fillet F (air) |
create_airway_fillet({procedure, vertex?, radius?}) |
Intra-procedure fillet (AAA): rounds an interior corner of ONE procedure. Pass {procedure, vertex} (vertex = position of the corner fix in airwayNodeIdxs, 1..len-2) or omit vertex to auto-pick the sharpest filletable corner. radius is 50..500 GU (default 120; clamped down to the leg length). Connected-only (no virtual); validates angle 5..175°, parallel guard; replaces the corner fix with T1 + arc-interior + T2 nameless nodes and reroutes airwayNodeIdxs. Inter-procedure (procA != procB) is rejected — matching the UI, where after picking an edge of AAA the snap/select is locked to AAA. |
| Select / Box Select | get_ground_painter_state + delete_ground_objects/delete_airway_objects |
Selection itself is UI-only; MCP reads the graph via get_ground_painter_state and deletes via point query (see below). |
| Delete (single ground) | delete_ground_objects({target:{x,z}, threshold?}) |
Deletes the nearest ground object to target using the same priority as the UI Select tool: stand > segment/runway > area (SKILL.md: pickForeground). threshold defaults to 0.6 GU. For runways cascades to strips + threshold nodes; records meta.deletedPks/deletedAreaIds/deletedStandNames/deletedRunwayNames and does orphan-node GC so the delete persists through patchSceneryBlob; pushes paired history. |
| Delete (single air) | delete_airway_objects({target:{x,z}, threshold?}) |
Deletes the nearest air object to target (airwayNode > procedure priority, threshold 0.6 GU). Node delete cascades to degenerate procedures (<2 nodes); records meta.deletedAirwayPks / airwayNodeOrigPk/airwaySegOrigPk GC so save persists; pushes paired history. |
| Move objects (ground) | move_ground_objects({targets:[{x,z}×≥1] OR selectAll:true, dx, dz, threshold?}) |
Translate 1..N ground objects (mirrors Select body-drag: single/multi/selectAll). Targets resolve like delete (ground). Runway moves carry coupled pavement strips; areas translate polygon points. Pushes paired history. |
| Move objects (air) | move_airway_objects({targets:[{x,z}×≥1] OR selectAll:true, dx, dz, threshold?}) |
Translate 1..N air objects (airway node / procedure body). Targets resolve like delete_airway; a procedure move carries its member nodes; selectAll moves every airway node. Pushes paired history. |
| Move endpoint/vertex | `move_ground_endpoint({target:{x,z}, to:{x,z}, kind:'node' | 'areaVertex'})` |
| Rename (ground) | `rename_ground_object({kind:'segment' | 'runway' |
| Rename (air) | `rename_airway_object({kind:'airwayNode' | 'procedure', idx, name})` |
| Undo | undo_ground_painter({}) |
Depth-1 undo — restores both groundPainterGraph and groundPainterMeta (paired groundPainterHistory/groundPainterMetaHistory), matching the UI's Ctrl+Z (appStore.js:setGroundPainterGraphWithMeta). Returns {undone:true/false}. |
10.2 State
get_ground_painter_state→{ graph, summary, metaSummary, tool, mode, isOpen, hasEdited, historyDepth }.summary:{nodes, segments, runways, areas, stands, airwayNodes, procedures};metaSummary:{nodeOrigPk, segOrigPk, deletedPks, deletedAreaIds, deletedStandNames, deletedRunwayNames, runwayPavement, runwayOrigInfo, airwayNodeOrigPk, airwaySegOrigPk, deletedAirwayPks}(for write-path debugging).mode:ground|air. Ifgraphisnullthe painter is not seeded — open the Ground Painter UI once; the MCP now returns a friendly{success:false, error:'Ground Painter not yet initialized — open the Ground Painter UI once…'}instead of a raw-32603.
10.3 Lifecycle & Guarantees
- Paired history: every mutating MCP tool (
create_*,create_runways,create_taxiway_fillet,create_airway_*,delete_*,move_*,rename_*) pushesgroundPainterHistoryandgroundPainterMetaHistoryvia_pushPainterHistorybefore mutating, soundo_ground_painterrestores a consistent pair (no meta-graph drift). - Meta persistence: creates push
nullintonodeOrigPk/segOrigPk/runwayOrigPk/areaOrigId/standOrigPk/airwayNodeOrigPk/airwaySegOrigPkfor new objects; deletes push the survivor's PK/id intodeletedPks/deletedAreaIds/deletedAirwayPksand run orphan-node GC (adjustingrunwayPavementindices), sopatchSceneryBlobdrops them at save — including jetway cascade (_reconcileJetwayFrames) for deleted stands and checkpoint-frame reconciliation for runways (_reconcilePhysicalRunwayFrames) + airway dangling gate forairway-node. - Node dedup:
create_taxiway_linesreuses an existing node when a coordinate matches within 1e-6 (_findNodeIndexByCoord), preserving shared junctions;create_stands/create_runways/create_airway_nodescreate fresh nodes for their geometry (airway nodes are always new, never deduped). - Validation: distinct-endpoint checks (
Segment needs distinct endpoints), area ≥3 vertices,areaType0|1|2, runway distinct endpoints, fillet straight-only + angle/parallel/radius guards, airway idx range + duplicate(name,runway,routeType), segment/procedure-index range checks, andtargettype checks all return{success:false, error}withisError:true(no raw throw). - In-memory until Save: all edits are in-memory until the painter's Save (confirm prompt with
.bakcheckbox) — or Cancel which discards. The painter renders as a full-screen fixed overlay (z-index:9999) that visually covers the editor; the MCP API is not gated —create_flights/modify_flightsstill run while the painter is open. A 0-runway graph cannot be saved: the paintersave()blocks withground_painter_validation_runway_required, and thesave-ground-painter-datahandler refuses the write (0runway:static keys) because the game throwsArgumentException: runway.initialRunways is empty. A runway created viacreate_runwaysis auto-added to the level'sInitialRunways(its primary end), so a fresh runway is active on load. - Flight reconciliation: after the scenery write,
save-ground-painter-datacompares the saved file's stands/runways against the pre-save snapshot, remaps renamed runway ends/stand identifiers (index-parallelrunwayOrigInfo/runwayOrigPk+ nose-position matching, existence-guarded), purges flights whoseStand/Runwayno longer resolves, rebuilds flight sections viagenerateFullAcl+ CSV sync, and returnspurgedFlights/refsRemapped(the renderer's snapshot is refreshed fromreadAclTextwhen flight sections were rebuilt). - Degenerate-edge guards: node drags freeze when consecutive vertices would colocate;
attachVirtualFilletLeg/applyVirtualFilletreject collapsed legs/arcs and same-anchor fillets before mutating;create_areasand area vertex drags reject bowtie polygons viapolygonIsSimple(src/acl/scenery_graph.js+ ESM mirrorpolygon_simple.js); air fillet rejects non-connected (virtual) pairs. - Runway designation dedup:
create_runwayssuffix-deduplicates heading-derived names (L/RthenC) so two runways never share a$k: runway:<name>key. - Jetway & airway integrity: stand deletes via
delete_ground_objectsrecord PKs, so the save dropsjetway:*static items and their checkpointjetway:*runtime entities — no moreJetway: static item 'jetway:NN' does not existat level init. Airway node deletes cascade to degenerate procedures (<2nodes) and are guarded by the airway dangling gate (airway-node→airway-segment/runway Routes), so no stray$irefto a deleted FIX survives.