aboutsummaryrefslogtreecommitdiff
path: root/backend
diff options
context:
space:
mode:
authorArda Serdar Pektezol <1669855+pektezol@users.noreply.github.com>2024-10-16 21:13:54 +0300
committerArda Serdar Pektezol <1669855+pektezol@users.noreply.github.com>2024-10-16 21:13:54 +0300
commit92447e02e5fc3977c9cfbca7a8de4132cbb4f13b (patch)
tree43a0ecda9c2eaeaf5af39df97ab4ad5083459c2b /backend
parentrefactor: upload run dialog (diff)
downloadlphub-92447e02e5fc3977c9cfbca7a8de4132cbb4f13b.tar.gz
lphub-92447e02e5fc3977c9cfbca7a8de4132cbb4f13b.tar.bz2
lphub-92447e02e5fc3977c9cfbca7a8de4132cbb4f13b.zip
refactor: upload run logic improvement
Diffstat (limited to 'backend')
-rw-r--r--backend/handlers/record.go94
-rw-r--r--backend/parser/parser.go196
2 files changed, 244 insertions, 46 deletions
diff --git a/backend/handlers/record.go b/backend/handlers/record.go
index fea6b5d..2338097 100644
--- a/backend/handlers/record.go
+++ b/backend/handlers/record.go
@@ -24,10 +24,9 @@ import (
24) 24)
25 25
26type RecordRequest struct { 26type RecordRequest struct {
27 HostDemo *multipart.FileHeader `json:"host_demo" form:"host_demo" binding:"required" swaggerignore:"true"` 27 HostDemo *multipart.FileHeader `json:"host_demo" form:"host_demo" binding:"required" swaggerignore:"true"`
28 PartnerDemo *multipart.FileHeader `json:"partner_demo" form:"partner_demo" swaggerignore:"true"` 28 PartnerDemo *multipart.FileHeader `json:"partner_demo" form:"partner_demo" swaggerignore:"true"`
29 IsPartnerOrange bool `json:"is_partner_orange" form:"is_partner_orange"` 29 PartnerID string `json:"partner_id" form:"partner_id"`
30 PartnerID string `json:"partner_id" form:"partner_id"`
31} 30}
32 31
33type RecordResponse struct { 32type RecordResponse struct {
@@ -50,7 +49,12 @@ type RecordResponse struct {
50// @Success 200 {object} models.Response{data=RecordResponse} 49// @Success 200 {object} models.Response{data=RecordResponse}
51// @Router /maps/{mapid}/record [post] 50// @Router /maps/{mapid}/record [post]
52func CreateRecordWithDemo(c *gin.Context) { 51func CreateRecordWithDemo(c *gin.Context) {
53 mapId := c.Param("mapid") 52 id := c.Param("mapid")
53 mapID, err := strconv.Atoi(id)
54 if err != nil {
55 c.JSON(http.StatusOK, models.ErrorResponse(err.Error()))
56 return
57 }
54 // Check if user exists 58 // Check if user exists
55 user, exists := c.Get("user") 59 user, exists := c.Get("user")
56 if !exists { 60 if !exists {
@@ -62,7 +66,7 @@ func CreateRecordWithDemo(c *gin.Context) {
62 var isCoop bool 66 var isCoop bool
63 var isDisabled bool 67 var isDisabled bool
64 sql := `SELECT g.name, m.is_disabled FROM maps m INNER JOIN games g ON m.game_id=g.id WHERE m.id = $1` 68 sql := `SELECT g.name, m.is_disabled FROM maps m INNER JOIN games g ON m.game_id=g.id WHERE m.id = $1`
65 err := database.DB.QueryRow(sql, mapId).Scan(&gameName, &isDisabled) 69 err = database.DB.QueryRow(sql, mapID).Scan(&gameName, &isDisabled)
66 if err != nil { 70 if err != nil {
67 c.JSON(http.StatusOK, models.ErrorResponse(err.Error())) 71 c.JSON(http.StatusOK, models.ErrorResponse(err.Error()))
68 return 72 return
@@ -94,8 +98,8 @@ func CreateRecordWithDemo(c *gin.Context) {
94 var hostDemoUUID, hostDemoFileID, partnerDemoUUID, partnerDemoFileID string 98 var hostDemoUUID, hostDemoFileID, partnerDemoUUID, partnerDemoFileID string
95 var hostDemoScoreCount, hostDemoScoreTime int 99 var hostDemoScoreCount, hostDemoScoreTime int
96 var hostSteamID, partnerSteamID string 100 var hostSteamID, partnerSteamID string
97 client := serviceAccount() 101 var hostDemoServerNumber, partnerDemoServerNumber int
98 srv, err := drive.New(client) 102 srv, err := drive.New(serviceAccount())
99 if err != nil { 103 if err != nil {
100 c.JSON(http.StatusOK, models.ErrorResponse(err.Error())) 104 c.JSON(http.StatusOK, models.ErrorResponse(err.Error()))
101 return 105 return
@@ -131,13 +135,22 @@ func CreateRecordWithDemo(c *gin.Context) {
131 c.JSON(http.StatusOK, models.ErrorResponse(err.Error())) 135 c.JSON(http.StatusOK, models.ErrorResponse(err.Error()))
132 return 136 return
133 } 137 }
134 hostDemoScoreCount, hostDemoScoreTime, hostSteamID, partnerSteamID, err = parser.ProcessDemo("backend/parser/" + uuid + ".dem") 138 parserResult, err := parser.ProcessDemo("backend/parser/" + uuid + ".dem")
135 if err != nil { 139 if err != nil {
136 deleteFile(srv, file.Id) 140 deleteFile(srv, file.Id)
137 CreateLog(user.(models.User).SteamID, LogTypeRecord, LogDescriptionCreateRecordProcessDemoFail, err.Error()) 141 CreateLog(user.(models.User).SteamID, LogTypeRecord, LogDescriptionCreateRecordProcessDemoFail, err.Error())
138 c.JSON(http.StatusOK, models.ErrorResponse(err.Error())) 142 c.JSON(http.StatusOK, models.ErrorResponse(err.Error()))
139 return 143 return
140 } 144 }
145 if mapID != parserResult.MapID {
146 deleteFile(srv, file.Id)
147 c.JSON(http.StatusOK, models.ErrorResponse("demo map does not match uploaded map id"))
148 return
149 }
150 hostDemoScoreCount = parserResult.PortalCount
151 hostDemoScoreTime = parserResult.TickCount
152 hostSteamID = parserResult.HostSteamID
153 partnerSteamID = parserResult.PartnerSteamID
141 if hostDemoScoreCount == 0 && hostDemoScoreTime == 0 { 154 if hostDemoScoreCount == 0 && hostDemoScoreTime == 0 {
142 deleteFile(srv, file.Id) 155 deleteFile(srv, file.Id)
143 CreateLog(user.(models.User).SteamID, LogTypeRecord, LogDescriptionCreateRecordProcessDemoFail, err.Error()) 156 CreateLog(user.(models.User).SteamID, LogTypeRecord, LogDescriptionCreateRecordProcessDemoFail, err.Error())
@@ -145,30 +158,32 @@ func CreateRecordWithDemo(c *gin.Context) {
145 return 158 return
146 } 159 }
147 if !isCoop { 160 if !isCoop {
148 hostSplit := strings.Split(hostSteamID, ":") 161 convertedSteamID := strconv.FormatInt(convertSteamID64(hostSteamID), 10)
149 if hostSplit[len(hostSplit)-1] != user.(models.User).SteamID { 162 if convertedSteamID != user.(models.User).SteamID {
150 c.JSON(http.StatusOK, models.ErrorResponse(fmt.Sprintf("Host SteamID from demo and request does not match! Check your submission and try again.\nDemo Host SteamID: %s\nRequest Host SteamID: %s", hostSplit[len(hostSplit)-1], user.(models.User).SteamID))) 163 deleteFile(srv, file.Id)
164 c.JSON(http.StatusOK, models.ErrorResponse(fmt.Sprintf("Host SteamID from demo and request does not match! Check your submission and try again.\nDemo Host SteamID: %s\nRequest Host SteamID: %s", convertedSteamID, user.(models.User).SteamID)))
151 return 165 return
152 } 166 }
153 } else { 167 } else {
154 partnerSplit := strings.Split(partnerSteamID, ":") 168 if parserResult.IsHost && i != 0 {
155 if partnerSplit[len(partnerSplit)-1] != record.PartnerID { 169 deleteFile(srv, file.Id)
156 c.JSON(http.StatusOK, models.ErrorResponse(fmt.Sprintf("Partner SteamID from demo and request does not match! Check your submission and try again.\nDemo Partner SteamID: %s\nRequest Partner SteamID: %s", partnerSplit[len(partnerSplit)-1], record.PartnerID))) 170 c.JSON(http.StatusOK, models.ErrorResponse("Given partner demo is a host demo."))
157 return 171 return
158 } 172 }
159 var verifyCoopSteamID string 173 if !parserResult.IsHost && i == 0 {
160 database.DB.QueryRow("SELECT steam_id FROM users WHERE steam_id = $1", record.PartnerID).Scan(&verifyCoopSteamID) 174 deleteFile(srv, file.Id)
161 if verifyCoopSteamID != record.PartnerID { 175 c.JSON(http.StatusOK, models.ErrorResponse("Given host demo is a partner demo."))
162 c.JSON(http.StatusOK, models.ErrorResponse("Given partner SteamID does not match an account on LPHUB."))
163 return 176 return
164 } 177 }
165 } 178 }
166 if i == 0 { 179 if i == 0 {
167 hostDemoFileID = file.Id 180 hostDemoFileID = file.Id
168 hostDemoUUID = uuid 181 hostDemoUUID = uuid
182 hostDemoServerNumber = parserResult.ServerNumber
169 } else if i == 1 { 183 } else if i == 1 {
170 partnerDemoFileID = file.Id 184 partnerDemoFileID = file.Id
171 partnerDemoUUID = uuid 185 partnerDemoUUID = uuid
186 partnerDemoServerNumber = parserResult.ServerNumber
172 } 187 }
173 _, err = tx.Exec(`INSERT INTO demos (id,location_id) VALUES ($1,$2)`, uuid, file.Id) 188 _, err = tx.Exec(`INSERT INTO demos (id,location_id) VALUES ($1,$2)`, uuid, file.Id)
174 if err != nil { 189 if err != nil {
@@ -180,18 +195,37 @@ func CreateRecordWithDemo(c *gin.Context) {
180 } 195 }
181 // Insert into records 196 // Insert into records
182 if isCoop { 197 if isCoop {
198 if hostDemoServerNumber != partnerDemoServerNumber {
199 deleteFile(srv, hostDemoFileID)
200 deleteFile(srv, partnerDemoFileID)
201 c.JSON(http.StatusOK, models.ErrorResponse(fmt.Sprintf("Host and partner demo server numbers (%d & %d) does not match!", hostDemoServerNumber, partnerDemoServerNumber)))
202 return
203 }
204 convertedHostSteamID := strconv.FormatInt(convertSteamID64(hostSteamID), 10)
205 if convertedHostSteamID != user.(models.User).SteamID && convertedHostSteamID != record.PartnerID {
206 deleteFile(srv, hostDemoFileID)
207 deleteFile(srv, partnerDemoFileID)
208 c.JSON(http.StatusOK, models.ErrorResponse(fmt.Sprintf("Host SteamID from demo and request does not match! Check your submission and try again.\nDemo Host SteamID: %s\nRequest Host SteamID: %s", convertedHostSteamID, user.(models.User).SteamID)))
209 return
210 }
211 convertedPartnerSteamID := strconv.FormatInt(convertSteamID64(partnerSteamID), 10)
212 if convertedPartnerSteamID != record.PartnerID && convertedPartnerSteamID != user.(models.User).SteamID {
213 deleteFile(srv, hostDemoFileID)
214 deleteFile(srv, partnerDemoFileID)
215 c.JSON(http.StatusOK, models.ErrorResponse(fmt.Sprintf("Partner SteamID from demo and request does not match! Check your submission and try again.\nDemo Partner SteamID: %s\nRequest Partner SteamID: %s", convertedPartnerSteamID, record.PartnerID)))
216 return
217 }
218 var verifyPartnerSteamID string
219 database.DB.QueryRow("SELECT steam_id FROM users WHERE steam_id = $1", record.PartnerID).Scan(&verifyPartnerSteamID)
220 if verifyPartnerSteamID != record.PartnerID {
221 deleteFile(srv, hostDemoFileID)
222 deleteFile(srv, partnerDemoFileID)
223 c.JSON(http.StatusOK, models.ErrorResponse("Given partner SteamID does not match an account on LPHUB."))
224 return
225 }
183 sql := `INSERT INTO records_mp(map_id,score_count,score_time,host_id,partner_id,host_demo_id,partner_demo_id) 226 sql := `INSERT INTO records_mp(map_id,score_count,score_time,host_id,partner_id,host_demo_id,partner_demo_id)
184 VALUES($1, $2, $3, $4, $5, $6, $7)` 227 VALUES($1, $2, $3, $4, $5, $6, $7)`
185 var hostID string 228 _, err := tx.Exec(sql, mapID, hostDemoScoreCount, hostDemoScoreTime, strconv.FormatInt(convertSteamID64(hostSteamID), 10), strconv.FormatInt(convertSteamID64(partnerSteamID), 10), hostDemoUUID, partnerDemoUUID)
186 var partnerID string
187 if record.IsPartnerOrange {
188 hostID = user.(models.User).SteamID
189 partnerID = record.PartnerID
190 } else {
191 partnerID = user.(models.User).SteamID
192 hostID = record.PartnerID
193 }
194 _, err := tx.Exec(sql, mapId, hostDemoScoreCount, hostDemoScoreTime, hostID, partnerID, hostDemoUUID, partnerDemoUUID)
195 if err != nil { 229 if err != nil {
196 deleteFile(srv, hostDemoFileID) 230 deleteFile(srv, hostDemoFileID)
197 deleteFile(srv, partnerDemoFileID) 231 deleteFile(srv, partnerDemoFileID)
@@ -202,7 +236,7 @@ func CreateRecordWithDemo(c *gin.Context) {
202 } else { 236 } else {
203 sql := `INSERT INTO records_sp(map_id,score_count,score_time,user_id,demo_id) 237 sql := `INSERT INTO records_sp(map_id,score_count,score_time,user_id,demo_id)
204 VALUES($1, $2, $3, $4, $5)` 238 VALUES($1, $2, $3, $4, $5)`
205 _, err := tx.Exec(sql, mapId, hostDemoScoreCount, hostDemoScoreTime, user.(models.User).SteamID, hostDemoUUID) 239 _, err := tx.Exec(sql, mapID, hostDemoScoreCount, hostDemoScoreTime, user.(models.User).SteamID, hostDemoUUID)
206 if err != nil { 240 if err != nil {
207 deleteFile(srv, hostDemoFileID) 241 deleteFile(srv, hostDemoFileID)
208 CreateLog(user.(models.User).SteamID, LogTypeRecord, LogDescriptionCreateRecordInsertRecordFail, err.Error()) 242 CreateLog(user.(models.User).SteamID, LogTypeRecord, LogDescriptionCreateRecordInsertRecordFail, err.Error())
diff --git a/backend/parser/parser.go b/backend/parser/parser.go
index 1a80d4a..4605600 100644
--- a/backend/parser/parser.go
+++ b/backend/parser/parser.go
@@ -4,30 +4,52 @@ import (
4 "errors" 4 "errors"
5 "math" 5 "math"
6 "os" 6 "os"
7 "regexp"
8 "strconv"
9 "strings"
7 10
8 "github.com/pektezol/bitreader" 11 "github.com/pektezol/bitreader"
9) 12)
10 13
14type Result struct {
15 MapID int
16 ServerNumber int
17 PortalCount int
18 TickCount int
19 HostSteamID string
20 PartnerSteamID string
21 IsHost bool
22}
23
11// Don't try to understand it, feel it. 24// Don't try to understand it, feel it.
12func ProcessDemo(filePath string) (portalCount int, tickCount int, hostSteamID string, partnerSteamID string, err error) { 25func ProcessDemo(filePath string) (Result, error) {
26 var result Result
13 file, err := os.Open(filePath) 27 file, err := os.Open(filePath)
14 if err != nil { 28 if err != nil {
15 return 0, 0, "", "", err 29 return Result{}, err
16 } 30 }
17 reader := bitreader.NewReader(file, true) 31 reader := bitreader.NewReader(file, true)
18 demoFileStamp := reader.TryReadString() 32 demoFileStamp := reader.TryReadString()
19 demoProtocol := reader.TryReadSInt32() 33 demoProtocol := reader.TryReadSInt32()
20 networkProtocol := reader.TryReadSInt32() 34 networkProtocol := reader.TryReadSInt32()
21 reader.SkipBytes(1056) 35 serverName := reader.TryReadStringLength(260)
36 // clientName := reader.TryReadStringLength(260)
37 reader.SkipBytes(260)
38 mapName := reader.TryReadStringLength(260)
39 reader.SkipBytes(276)
22 if demoFileStamp != "HL2DEMO" { 40 if demoFileStamp != "HL2DEMO" {
23 return 0, 0, "", "", errors.New("invalid demo file stamp") 41 return Result{}, errors.New("invalid demo file stamp")
24 } 42 }
25 if demoProtocol != 4 { 43 if demoProtocol != 4 {
26 return 0, 0, "", "", errors.New("this parser only supports demos from new engine") 44 return Result{}, errors.New("this parser only supports demos from new engine")
27 } 45 }
28 if networkProtocol != 2001 { 46 if networkProtocol != 2001 {
29 return 0, 0, "", "", errors.New("this parser only supports demos from portal 2") 47 return Result{}, errors.New("this parser only supports demos from portal 2")
48 }
49 if mapDict[mapName] == 0 {
50 return Result{}, errors.New("demo recorded on an invalid map")
30 } 51 }
52 result.MapID = mapDict[mapName]
31 for { 53 for {
32 packetType := reader.TryReadUInt8() 54 packetType := reader.TryReadUInt8()
33 reader.SkipBits(40) 55 reader.SkipBits(40)
@@ -119,7 +141,17 @@ func ProcessDemo(filePath string) (portalCount int, tickCount int, hostSteamID s
119 packetReader.SkipBytes(2) 141 packetReader.SkipBytes(2)
120 packetReader.SkipBits(uint64(packetReader.TryReadUInt16())) 142 packetReader.SkipBits(uint64(packetReader.TryReadUInt16()))
121 case 16: 143 case 16:
122 packetReader.TryReadString() 144 print := packetReader.TryReadString()
145 re := regexp.MustCompile(`Server Number: (\d+)`)
146 match := re.FindStringSubmatch(print)
147 if len(match) >= 1 {
148 serverNumber := match[1]
149 n, err := strconv.Atoi(serverNumber)
150 if err != nil {
151 return Result{}, err
152 }
153 result.ServerNumber = n
154 }
123 case 17: 155 case 17:
124 var length uint16 156 var length uint16
125 if packetReader.TryReadBool() { 157 if packetReader.TryReadBool() {
@@ -180,8 +212,8 @@ func ProcessDemo(filePath string) (portalCount int, tickCount int, hostSteamID s
180 NumPortals: userMessageReader.TryReadSInt32(), 212 NumPortals: userMessageReader.TryReadSInt32(),
181 TimeTaken: userMessageReader.TryReadSInt32(), 213 TimeTaken: userMessageReader.TryReadSInt32(),
182 } 214 }
183 portalCount = int(scoreboardTempUpdate.NumPortals) 215 result.PortalCount = int(scoreboardTempUpdate.NumPortals)
184 tickCount = int(math.Round(float64((float32(scoreboardTempUpdate.TimeTaken) / 100.0) / float32(1.0/60.0)))) 216 result.TickCount = int(math.Round(float64((float32(scoreboardTempUpdate.TimeTaken) / 100.0) / float32(1.0/60.0))))
185 } 217 }
186 case 24: 218 case 24:
187 packetReader.SkipBits(20) 219 packetReader.SkipBits(20)
@@ -216,8 +248,8 @@ func ProcessDemo(filePath string) (portalCount int, tickCount int, hostSteamID s
216 packetReader.SkipBytes(packetReader.TryReadBits(32)) 248 packetReader.SkipBytes(packetReader.TryReadBits(32))
217 case 33: 249 case 33:
218 packetReader.SkipBits(packetReader.TryReadBits(32)) 250 packetReader.SkipBits(packetReader.TryReadBits(32))
219 default: 251 // default:
220 return 0, 0, "", "", errors.New("unknown msg type") 252 // return Result{}, errors.New(fmt.Sprintf("unknown msg type %d", messageType))
221 } 253 }
222 } 254 }
223 case 3, 7: 255 case 3, 7:
@@ -252,7 +284,7 @@ func ProcessDemo(filePath string) (portalCount int, tickCount int, hostSteamID s
252 if stringTableReader.TryReadBool() { 284 if stringTableReader.TryReadBool() {
253 byteLen, err := stringTableReader.ReadBits(16) 285 byteLen, err := stringTableReader.ReadBits(16)
254 if err != nil { 286 if err != nil {
255 return 0, 0, "", "", errors.New("error on reading entry length") 287 return Result{}, errors.New("error on reading entry length")
256 } 288 }
257 stringTableEntryReader := bitreader.NewReaderFromBytes(stringTableReader.TryReadBytesToSlice(byteLen), true) 289 stringTableEntryReader := bitreader.NewReaderFromBytes(stringTableReader.TryReadBytesToSlice(byteLen), true)
258 if tableName == "userinfo" { 290 if tableName == "userinfo" {
@@ -285,10 +317,14 @@ func ProcessDemo(filePath string) (portalCount int, tickCount int, hostSteamID s
285 userInfo.FilesDownloaded = stringTableEntryReader.TryReadUInt8() 317 userInfo.FilesDownloaded = stringTableEntryReader.TryReadUInt8()
286 stringTableEntryReader.SkipBytes(3) 318 stringTableEntryReader.SkipBytes(3)
287 if guidCount == 0 { 319 if guidCount == 0 {
288 hostSteamID = userInfo.GUID 320 result.HostSteamID = userInfo.GUID
321 if strings.Contains(serverName, "localhost") {
322 result.IsHost = true
323 }
289 } else if guidCount == 1 { 324 } else if guidCount == 1 {
290 partnerSteamID = userInfo.GUID 325 result.PartnerSteamID = userInfo.GUID
291 } 326 }
327 guidCount++
292 } 328 }
293 } 329 }
294 } 330 }
@@ -303,11 +339,139 @@ func ProcessDemo(filePath string) (portalCount int, tickCount int, hostSteamID s
303 } 339 }
304 } 340 }
305 default: 341 default:
306 return 0, 0, "", "", errors.New("invalid packet type") 342 return Result{}, errors.New("invalid packet type")
307 } 343 }
308 if packetType == 7 { 344 if packetType == 7 {
309 break 345 break
310 } 346 }
311 } 347 }
312 return portalCount, tickCount, hostSteamID, partnerSteamID, nil 348 return result, nil
349}
350
351var mapDict = map[string]int{
352 "sp_a1_intro1": 1,
353 "sp_a1_intro2": 2,
354 "sp_a1_intro3": 3,
355 "sp_a1_intro4": 4,
356 "sp_a1_intro5": 5,
357 "sp_a1_intro6": 6,
358 "sp_a1_intro7": 7,
359 "sp_a1_wakeup": 8,
360 "sp_a2_intro": 9,
361
362 "sp_a2_laser_intro": 10,
363 "sp_a2_laser_stairs": 11,
364 "sp_a2_dual_lasers": 12,
365 "sp_a2_laser_over_goo": 13,
366 "sp_a2_catapult_intro": 14,
367 "sp_a2_trust_fling": 15,
368 "sp_a2_pit_flings": 16,
369 "sp_a2_fizzler_intro": 17,
370
371 "sp_a2_sphere_peek": 18,
372 "sp_a2_ricochet": 19,
373 "sp_a2_bridge_intro": 20,
374 "sp_a2_bridge_the_gap": 21,
375 "sp_a2_turret_intro": 22,
376 "sp_a2_laser_relays": 23,
377 "sp_a2_turret_blocker": 24,
378 "sp_a2_laser_vs_turret": 25,
379 "sp_a2_pull_the_rug": 26,
380
381 "sp_a2_column_blocker": 27,
382 "sp_a2_laser_chaining": 28,
383 "sp_a2_triple_laser": 29,
384 "sp_a2_bts1": 30,
385 "sp_a2_bts2": 31,
386
387 "sp_a2_bts3": 32,
388 "sp_a2_bts4": 33,
389 "sp_a2_bts5": 34,
390 "sp_a2_core": 35,
391
392 "sp_a3_01": 36,
393 "sp_a3_03": 37,
394 "sp_a3_jump_intro": 38,
395 "sp_a3_bomb_flings": 39,
396 "sp_a3_crazy_box": 40,
397 "sp_a3_transition01": 41,
398
399 "sp_a3_speed_ramp": 42,
400 "sp_a3_speed_flings": 43,
401 "sp_a3_portal_intro": 44,
402 "sp_a3_end": 45,
403
404 "sp_a4_intro": 46,
405 "sp_a4_tb_intro": 47,
406 "sp_a4_tb_trust_drop": 48,
407 "sp_a4_tb_wall_button": 49,
408 "sp_a4_tb_polarity": 50,
409 "sp_a4_tb_catch": 51,
410 "sp_a4_stop_the_box": 52,
411 "sp_a4_laser_catapult": 53,
412 "sp_a4_laser_platform": 54,
413 "sp_a4_speed_catch": 55,
414 "sp_a4_jump_polarity": 56,
415
416 "sp_a4_finale1": 57,
417 "sp_a4_finale2": 58,
418 "sp_a4_finale3": 59,
419 "sp_a4_finale4": 60,
420
421 "mp_coop_start": 61,
422 "mp_coop_lobby_2": 62,
423
424 "mp_coop_doors": 63,
425 "mp_coop_race_2": 64,
426 "mp_coop_laser_2": 65,
427 "mp_coop_rat_maze": 66,
428 "mp_coop_laser_crusher": 67,
429 "mp_coop_teambts": 68,
430
431 "mp_coop_fling_3": 69,
432 "mp_coop_infinifling_train": 70,
433 "mp_coop_come_along": 71,
434 "mp_coop_fling_1": 72,
435 "mp_coop_catapult_1": 73,
436 "mp_coop_multifling_1": 74,
437 "mp_coop_fling_crushers": 75,
438 "mp_coop_fan": 76,
439
440 "mp_coop_wall_intro": 77,
441 "mp_coop_wall_2": 78,
442 "mp_coop_catapult_wall_intro": 79,
443 "mp_coop_wall_block": 80,
444 "mp_coop_catapult_2": 81,
445 "mp_coop_turret_walls": 82,
446 "mp_coop_turret_ball": 83,
447 "mp_coop_wall_5": 84,
448
449 "mp_coop_tbeam_redirect": 85,
450 "mp_coop_tbeam_drill": 86,
451 "mp_coop_tbeam_catch_grind_1": 87,
452 "mp_coop_tbeam_laser_1": 88,
453 "mp_coop_tbeam_polarity": 89,
454 "mp_coop_tbeam_polarity2": 90,
455 "mp_coop_tbeam_polarity3": 91,
456 "mp_coop_tbeam_maze": 92,
457 "mp_coop_tbeam_end": 93,
458
459 "mp_coop_paint_come_along": 94,
460 "mp_coop_paint_redirect": 95,
461 "mp_coop_paint_bridge": 96,
462 "mp_coop_paint_walljumps": 97,
463 "mp_coop_paint_speed_flings": 98,
464 "mp_coop_paint_red_racer": 99,
465 "mp_coop_paint_speed_catch": 100,
466 "mp_coop_paint_longjump_intro": 101,
467
468 "mp_coop_seperation_1": 102,
469 "mp_coop_tripleaxis": 103,
470 "mp_coop_catapult_catch": 104,
471 "mp_coop_2paints_1bridge": 105,
472 "mp_coop_paint_conversion": 106,
473 "mp_coop_bridge_catch": 107,
474 "mp_coop_laser_tbeam": 108,
475 "mp_coop_paint_rat_maze": 109,
476 "mp_coop_paint_crazy_box": 110,
313} 477}