Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
658298ee86 |
No files matched your search
@@ -30,7 +30,7 @@ Gateway รองรับการค้นหา metadata ตามเวอ
|
||||
|
||||
เริ่มพอร์ต CW จาก [Currency-War-SR ของ xaodev](https://gaytea.cod-algieba.ts.net/xaodev/Currency-War-SR) แล้ว: เพิ่ม factory ของ Encounter หลัง Supply, ตารางตัวเลือก/ความยากที่ revision ตรงกัน และ EXP ตามโหมด/บท/Boss โดยยังรักษาเส้นทาง Battle ที่จับไว้เดิม งานนี้ยังเป็นการพอร์ตบางส่วนและยังไม่ได้ยืนยันเส้นทางใหม่ใน Client อ่านสถานะ ผลทดสอบ และงานส่งต่อใน [CW xaodev handoff](docs/CW-XAODEV-CLAUDE-HANDOFF.md)
|
||||
|
||||
คำสั่งเวลาใน GaCy-Srtool: `/faketime status` ดูเวลาเกม, `/faketime <Unix seconds>` หรือ `/faketime <วันที่ RFC3339 พร้อม timezone>` ตั้งเวลาจำลอง และ `/faketime off` คืนเวลาจริง เวลาจำลองเดินต่อและบันทึกใน `game_clock.state_file` จาก config (override ด้วย `GAC_GAME_CLOCK_STATE_FILE`) ใช้กับ Login/heartbeat; ไม่เปลี่ยนนาฬิกา Windows, network timeout หรือเวลา log หลังเปลี่ยนให้เปิดสมุดใหม่ หรือเข้าเกมใหม่หากหน้าจอเก็บตารางเวลาไว้
|
||||
คำสั่งเวลาใน GaCy-Srtool: `/faketime status` ดูเวลาเกม, `/faketime <Unix seconds>` หรือ `/faketime <วันที่ RFC3339 พร้อม timezone>` ตั้งเวลาจำลอง และ `/faketime off` คืนเวลาจริง สำหรับ Endgame ใช้ `/faketime window <เวลาเริ่ม Unix> <เวลาจบ Unix>` เพื่อวนเวลาภายในช่วงที่ตรวจจาก Client แล้ว โดยเริ่มที่กลางช่วง เวลาจะย้อนกลับเมื่อถึงปลายช่วงและส่งผลต่อกิจกรรมอื่นที่ใช้เวลาเกมด้วย ต้องตรวจช่วงใหม่เมื่อเปลี่ยน Client เวลาบันทึกใน `game_clock.state_file` จาก config (override ด้วย `GAC_GAME_CLOCK_STATE_FILE`) ใช้กับ Login/heartbeat; ไม่เปลี่ยนนาฬิกา Windows, network timeout หรือเวลา log หลังเปลี่ยนให้เปิดสมุดใหม่ หรือเข้าเกมใหม่หากหน้าจอเก็บตารางเวลาไว้
|
||||
|
||||
ฉาก Login รถไฟ Cyrene ใช้งานได้แล้ว โดยใช้ `LoginShow_UP` เปิด manifest ชุดเต็มตั้งแต่เริ่มเกม และผูก event จบกับคลิปแอนิเมชัน UP ผู้ใช้ยืนยันทั้งภาพรถไฟสีขาว ฉากชมพูม่วง กลีบดอกไม้ และการ Logout แล้วกลับเข้าใหม่แล้ว เครื่องมือสร้างชุดสำเนาและคืนค่าต้นฉบับ รวมถึงผลตรวจในเกมอยู่ใน [บันทึกฉาก Login](docs/login-cyrene-investigation.md)
|
||||
|
||||
@@ -461,6 +461,7 @@ Client ยืนยันแล้วว่าเริ่มรอบ เปิ
|
||||
พิมพ์ใน GM Console ของ GaCy srtool เมื่อเชื่อมต่อเซิร์ฟเวอร์:
|
||||
|
||||
- `/cw status` อ่านรอบ เงิน HP และเลเวลที่เซฟอยู่
|
||||
- `/cw end` ส่งคำขอจบรอบ CW ปัจจุบันแบบ **Quit** รวมถึงตอนค้างหน้าเลือก คำสั่งรอ heartbeat และจะไม่แทรกกลาง Battle; ให้เกมเชื่อมต่อไว้จนกว่าจะจบ Battle และตรวจ `/cw status` หรือ log `CW_GM applied end` ข้อความ queued หมายถึงรับเข้าคิวแล้วเท่านั้น การจบรอบใช้ settlement แบบ Quit เดิม ไม่ถือว่าชนะ
|
||||
- `/cw gold <จำนวนรวม>` ตั้งเงิน CW เช่น `/cw gold 1000`
|
||||
- `/cw hp <จำนวนรวม>` ตั้ง HP ภายในค่าสูงสุดของรอบ
|
||||
- `/cw exp <gain>` เพิ่ม EXP และคำนวณเลเวล/ความจุ/น้ำหนักร้านค้าตามกฎที่ดึงมา
|
||||
@@ -470,6 +471,8 @@ Client ยืนยันแล้วว่าเริ่มรอบ เปิ
|
||||
- `/cw equip <ItemID> [count]` เพิ่มอุปกรณ์ลงในกระเป๋า CW
|
||||
- `/cw help` แสดงรูปแบบคำสั่งทั้งหมด
|
||||
|
||||
การสุ่มห้องยังไม่ครบ: การเริ่มรอบและผลเลือกประตูยังอาศัย capture ของโหมด/Division ที่มีอยู่ จึงเห็นเส้นทางเดิมซ้ำได้ ห้องที่สร้างภายหลังมีการสุ่มศัตรูบางส่วน แต่ยังไม่ได้ยืนยันกฎสุ่มชนิดห้องและ PetPal กับ Client การใช้ `/cw end` ไม่ได้ทำให้รอบถัดไปสุ่มห้องครบ
|
||||
|
||||
การมอบของและการตอบรับคิวคำสั่งจะคอมมิตพร้อมกับการทำ snapshot CAS และการแทนที่ไฟล์แบบ atomic เพื่อป้องกันการมอบของซ้ำซ้อนหลังหลุดการเชื่อมต่อ หากคำสั่งถูกปฏิเสธจะบันทึก log `CW_GM rejected` โดยไม่บันทึกการมอบของบางส่วน รายการเหล่านี้ครอบคลุมเฉพาะข้อมูลที่ดึงมา ไม่ใช่ไอเทมทุกชิ้นใน Client
|
||||
|
||||
- `/cw list orb|consumable|forge` แสดงรหัสรางวัลที่ดึงมา
|
||||
@@ -489,7 +492,7 @@ Client ยืนยันแล้วว่าเริ่มรอบ เปิ
|
||||
|
||||
ไฟล์ `resources/ActivityPreviewPolicy.json` ใช้เลือกพรีแฟบหน้าต่างอีเวนต์จาก `PreviewGameplayCatalog.json` สำหรับพรีวิวอื่น ๆ ขณะนี้ถูกระงับไว้ก่อน เนื่องจากบันทึกจาก Client พบปัญหา asset รางวัลสูญหายและการค้นหาไอเทมผิดพลาดใน ActiveActivity จึงควรเปิดใช้งานแต่ละหน้าต่างใหม่เมื่อตรวจสอบ asset ที่ติดตั้ง, identity ของโมดูล และความเกี่ยวโยงของรางวัลเรียบร้อยแล้วเท่านั้น ส่วนการต่อสู้เฉพาะอีเวนต์และการกดรับรางวัลยังทำไม่เสร็จสมบูรณ์
|
||||
|
||||
หน้า Warp ส่งเฉพาะตู้ที่มีข้อมูลจาก Client และตรวจพบ prefab ใน `resources/GachaSandboxPolicy.json` เท่านั้น บันทึก Client พบว่า ID 2120 จากชุด handoff ไม่มีใน `GachaBasicInfo` ของ Client นี้ และ prefab ตู้จำกัดที่ตรวจสอบก็ไม่พบ การส่งตู้เหล่านั้นทำให้เปิดหน้าไม่ได้ จึงยังไม่สามารถปลดตู้ย้อนหลังจากชุด handoff ได้ การสุ่มถูกระงับไว้จนกว่าจะตรวจสอบ pool, การให้ไอเทม และ protocol กับ Client ได้ คำสั่ง `/unlockall warp` และ `/reload banner` ยังไม่ทำให้ตู้ที่ไม่ได้ตรวจสอบปรากฏใน Client
|
||||
หน้า Warp ใช้ตู้จำลองจากรายชื่อตัวละครที่นำเข้าและภาพจาก Client โดย adapter สร้างแท็บจากตู้มาตรฐานที่ตรวจพบ asset แล้ว การสุ่มตู้ตัวละครใช้กติกา sandbox ใน `resources/GachaSandboxPolicy.json` และให้วัตถุดิบตัวละครซ้ำตาม resource ของเกม ไม่ใช่ตู้หรือเรททางการ ตู้มาตรฐานและมือใหม่สุ่มจากตัวละครที่มีในรายชื่อที่นำเข้า ตู้จาก handoff ที่ไม่มีในตาราง Client จะไม่ถูกส่งโดยตรง เพราะทำให้ Client เปิดหน้าไม่ได้ การแสดงหลายแท็บ การสุ่ม และการออกจากหน้าใน Client ยังต้องตรวจยืนยันอีกครั้ง
|
||||
|
||||
ข้อมูลตารางที่ดึงมาระหว่างรันและข้อจำกัดการตรวจสอบในปัจจุบันถูกบันทึกไว้ใน [docs/offline-gameplay-continuation.md](docs/offline-gameplay-continuation.md) การคอมไพล์ handler เหล่านี้ผ่านไม่ได้ยืนยันว่าหน้าจอหรือระบบการเล่นใน Client จะทำงานได้สมบูรณ์
|
||||
|
||||
@@ -586,3 +589,5 @@ CW ล่าสุด: ผู้ใช้ยืนยัน Battle ห้อง
|
||||
|
||||
3. **ข้อจำกัดการใช้งานและการค้า (Non-Commercial / Educational Use):**
|
||||
- โปรเจกต์นี้จัดทำขึ้นเพื่อการวิจัย, การศึกษาโครงสร้างโปรโตคอลเครือข่าย และการใช้งานส่วนบุคคลเท่านั้น **ห้ามนำไปจำหน่าย แสวงหาผลกำไร หรือใช้ในเชิงพาณิชย์โดยไม่ได้รับอนุญาต**
|
||||
|
||||
Relic catalog: `resources/RelicClientCapture.json` records the four planar relic rows for sets 329 and 330 and their matching main/sub-affix groups read from the connected Client. `resources/InventoryItemConfig.json` includes the four templates; restart the game server to reload its cached catalog. A successful test or catalog reload does not establish that the Client has displayed or equipped the items.
|
||||
+4
-1
@@ -33,6 +33,9 @@ The project is currently under active development, targeting Client **4.6.53 OS*
|
||||
|
||||
#### CW Preparation Commands
|
||||
- `/cw status`: Display saved round, gold, HP, and player level.
|
||||
- `/cw end`: Queue a quit settlement for the active CW run, including when a choice is stuck. It applies on heartbeat after any active battle; `queued` does not mean the run has ended. Keep the game connected and check `/cw status` or the `CW_GM applied end` log.
|
||||
|
||||
CW room generation remains incomplete: the initial run and portal outcomes use captured presets, so the route may repeat. Some later enemies are generated, but the complete dynamic room/PetPal selection rules remain unverified. `/cw end` does not randomize a new run.
|
||||
- `/cw gold <amount>`: Set CW currency (e.g. `/cw gold 1000`).
|
||||
- `/cw hp <amount>`: Set current HP within maximum stage allowance.
|
||||
- `/cw exp <gain>`: Add EXP and apply captured level/capacity/shop-weight rules.
|
||||
@@ -60,7 +63,7 @@ The selected buff applies at the start of the next battle containing an actual C
|
||||
|
||||
`resources/ActivityPreviewPolicy.json` selects event-panel prefabs from `PreviewGameplayCatalog.json`. Additional previews are currently withheld: Client logs showed missing reward assets and invalid item lookups in ActiveActivity. Re-enable individual panels only after validating their installed assets, module identity and reward dependencies. Event-specific battles and reward claims remain incomplete.
|
||||
|
||||
Warp advertises only pools captured from this Client whose prefabs were verified in `resources/GachaSandboxPolicy.json`. The Client log reports that handoff ID 2120 is absent from its `GachaBasicInfo` table; the limited pool prefabs checked are also missing. Sending those IDs prevents the page from opening. Imported historical banners are therefore not unlocked, and draws are disabled until pool data, item grants and protocol fields are verified against this Client. The `/unlockall warp` and `/reload banner` commands do not make unverified banners visible.
|
||||
Warp uses sandbox character banners based on the imported roster and Client art, with a presentation adapter built from the verified standard panel. Character draws use the sandbox rules in `resources/GachaSandboxPolicy.json` and grant configured duplicate materials; these are not official pools or rates. Standard and novice draws use the imported roster. Handoff IDs missing from the Client table are not sent directly because they break page entry. Multi-tab presentation, draws and exiting the page still require Client verification.
|
||||
|
||||
Runtime table captures and current verification limits are recorded in [docs/offline-gameplay-continuation.md](offline-gameplay-continuation.md). Compiling these handlers does not confirm the corresponding Client screens or gameplay.
|
||||
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ GaCy-Srtool 的持久化配置提供独立的 Aha's Help 辅助选择器。等
|
||||
|
||||
`resources/ActivityPreviewPolicy.json` 负责从 `PreviewGameplayCatalog.json` 选择活动面板预制体。其余面板因客户端日志检测到奖励资产缺失或无效物品查询而被暂时封锁。待逐一验证已安装资产及依赖后方可恢复开启。
|
||||
|
||||
Warp 仅发送已从当前客户端捕获且确认预制体存在的卡池。客户端日志显示交接包的 2120 不在本机表中,已检查的限定卡池资源也缺失;抽卡暂时禁用,等待核实卡池、奖励与协议。
|
||||
Warp 使用已导入角色和客户端图片构造模拟卡池,抽卡使用 `resources/GachaSandboxPolicy.json` 中的沙盒规则,并按资源发放重复角色材料。交接包中客户端表缺少的 ID 不会直接发送;多标签、抽卡与退出页面仍需在客户端验证。
|
||||
|
||||
运行时捕获的数据表及当前验证边界均记录在 [docs/offline-gameplay-continuation.md](offline-gameplay-continuation.md)。
|
||||
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# Battle investigation — 2026-10-16
|
||||
|
||||
## Aha target when entering battle
|
||||
|
||||
The scene's `SceneCastSkillCsReq.CasterId` identifies the attacking actor;
|
||||
scene actor entity IDs are lineup slot + 1. The technique path also recognizes
|
||||
the avatar entity namespace. When that actor is present in the battle lineup,
|
||||
the server now sends its battle slot in the Aha Global and Aha's Help buffs'
|
||||
`TargetIndexList`. The configured passive owner and Aha Help owner are
|
||||
unchanged. Requests without a resolved player actor, including monster ambushes
|
||||
and direct stage entry, retain the previous target. This protocol-target mapping
|
||||
still requires an in-Client check of the resulting Aha effect.
|
||||
|
||||
## หลักฐานที่ใช้อ้างอิง
|
||||
|
||||
- Upstream: [HonkaiSlopRail/pearl-sr](https://git.xeondev.com/HonkaiSlopRail/pearl-sr)
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# Currency Wars route continuation audit
|
||||
|
||||
## 2026-11-01: stuck initial round and `/cw end`
|
||||
|
||||
The live server log rejected the initial RoundBegin at queue 2 with
|
||||
`CW starter pool reservation is not supported`. A read-only copy of the live
|
||||
snapshot showed a starter above its captured default star. On a detached copy,
|
||||
the same run advanced through `cwInitialRound` after changing initial pool
|
||||
accounting to use the existing captured `ReserveOwned`/merge rules, as later
|
||||
shop restocks already do. No live snapshot was modified by this check, and the
|
||||
new battle/Client path has not yet been verified.
|
||||
|
||||
`/cw end` queues a run- and UID-bound ordinary quit settlement, including
|
||||
while a pending choice is present. An active Battle defers it until afterward.
|
||||
The quit record, archive, removal of the run and acknowledgement of the command
|
||||
share the persistent CAS transaction. It reports queued rather than claiming
|
||||
success before heartbeat. It does not declare a win or randomize a new run.
|
||||
|
||||
The reported repeated rooms are consistent with `CurrencyWarsInitialRuns.json`
|
||||
and `CurrencyWarsEntryTransitions.json`, which replay captured initial states
|
||||
and portal outcomes. Later factory paths can generate some enemies, but the
|
||||
dynamic room and PetPal replacement contract is still incomplete. No random
|
||||
node IDs or fabricated distributions were added; see the historical notes below.
|
||||
|
||||
**2026-10-31 update:** An initial xaodev-based generated EliteBranch factory
|
||||
has now been connected to the route fallback. The older unsupported-factory
|
||||
statements below describe the preceding implementation. The new code passes
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# CW start, promotion and 6900 investigation
|
||||
|
||||
## Findings (2026-11-02)
|
||||
|
||||
- `CurrencyWarsInitialRuns.json` contains one starting capture for each mode,
|
||||
both for division 20101. `buildCurrencyWarsInitialRun` copies that capture;
|
||||
the chosen portal then replaces the run with its recorded outcome from
|
||||
`CurrencyWarsEntryTransitions.json`. Its offered portals, starter roles,
|
||||
route displays and saved boss/camp choices are consequently repeated. The
|
||||
later enemy factory uses those saved boss/camp choices. This is independent
|
||||
of whether the player has been promoted; it is not a procedural start.
|
||||
- Terminal victory calls `currencyWarsTerminalSettlement`, which calculates
|
||||
and saves a division increase. Quit and `/cw end` are not wins and do not
|
||||
promote. The present ranking calculation is implementation policy, not a
|
||||
verified original-server formula. A promoted division has no matching
|
||||
initial capture, so a subsequent start for that division is rejected; no
|
||||
successful full-run Client verification is established here.
|
||||
- Retcode 6900 is `RET_GRID_FIGHT_CONF_MISS` and covers multiple errors. In
|
||||
the available root `gameserver.log`, the attempted pending action at queue 1
|
||||
with request arm `DCPHLHNLNKF` was rejected as unsupported, followed by a
|
||||
successful portal selection (`MFFCPLBNHCF`). The empty request payload does
|
||||
not by itself prove which button was pressed. It must not be assumed that
|
||||
every reported 6900 has this cause. The isolated `for ship` log instead
|
||||
records a later initial-round rejection on a two-star starter; that defect
|
||||
was fixed in `cwInitialRound`, but the old log cannot prove a newer binary
|
||||
was deployed or that the Client accepted it.
|
||||
- The revision-matching xaodev reference leaves portal reroll counts zero and
|
||||
explicitly does not offer rerolls. Its random portal generation depends on
|
||||
general effect handling, while this server only has recorded outcomes for
|
||||
the captured choices. Adding arbitrary portal IDs, returning success without
|
||||
applying their effects, or swapping in a different captured battle would
|
||||
conceal the missing behavior and risk the previously verified Battle route.
|
||||
|
||||
## Work still needed
|
||||
|
||||
Implement and validate a resource-backed run generator and portal effect
|
||||
transactions for each supported choice, with a coherent route, camp, boss,
|
||||
starter team, pending actions and battle payload. Cover promoted divisions
|
||||
without changing existing confirmed Battle wiring. Resolve the exact meaning
|
||||
of request arm `DCPHLHNLNKF` with revision-matching static or recorded evidence
|
||||
before implementing it. For a new 6900, identify the active executable and
|
||||
the corresponding `CW_PENDING_ACTION` rejection rather than relying on the
|
||||
numeric return code alone. Offline tests are necessary but do not establish
|
||||
Client gameplay acceptance.
|
||||
@@ -21,8 +21,15 @@ Use the command field in GaC-Srtool:
|
||||
- `/faketime 1791748801` sets Unix seconds.
|
||||
- `/faketime 2026-10-12T03:00:01+07:00` sets an explicit zoned date.
|
||||
- `/faketime off` restores real time (`reset` and `real` are equivalent).
|
||||
- `/faketime window <begin Unix seconds> <end Unix seconds>` keeps game time
|
||||
inside a Client-observed overlap. It starts at the midpoint, advances, and
|
||||
wraps to the beginning when the end is reached. Use only bounds verified for
|
||||
the installed Client; changing Client revision requires checking them again.
|
||||
|
||||
The fake clock keeps advancing. Its offset is atomically saved to
|
||||
The fake clock keeps advancing. Window mode wraps the shared game calendar for
|
||||
all game features, so the displayed time moves backwards at the boundary and
|
||||
other time-based activities may be affected. `/faketime off` or setting a new
|
||||
single time disables the window. Its state is atomically saved to
|
||||
`game_clock.state_file`, configured in `server.json`, local overrides, or
|
||||
`GAC_GAME_CLOCK_STATE_FILE`. With no state file configured, reads use real time
|
||||
and attempts to set fake time fail with a configuration error. Login and
|
||||
|
||||
@@ -19,6 +19,9 @@ type CurrencyWarsCommand struct {
|
||||
Star uint32 `json:"star,omitempty"`
|
||||
}
|
||||
|
||||
// CurrencyWarsCommandEnd requests the same settlement as quitting the run.
|
||||
const CurrencyWarsCommandEnd = "end"
|
||||
|
||||
func ReadCurrencyWarsCommands() ([]CurrencyWarsCommand, error) {
|
||||
root, err := readCurrencyWarsPersistentRoot()
|
||||
if err != nil {
|
||||
@@ -39,9 +42,12 @@ func QueueCurrencyWarsGrant(uid, runID uint32, field string, value, quantity, st
|
||||
if field == "tb" || field == "trailblaze" {
|
||||
field = "tbpower"
|
||||
}
|
||||
if uid == 0 || runID == 0 || quantity == 0 || (field != "gold" && field != "hp" && field != "exp" && field != "equip" && field != "role" && field != "orb" && field != "consumable" && field != "forge" && field != "tbpower") {
|
||||
if uid == 0 || runID == 0 || quantity == 0 || (field != CurrencyWarsCommandEnd && field != "gold" && field != "hp" && field != "exp" && field != "equip" && field != "role" && field != "orb" && field != "consumable" && field != "forge" && field != "tbpower") {
|
||||
return "", fmt.Errorf("invalid CW command")
|
||||
}
|
||||
if field == CurrencyWarsCommandEnd && (value != 0 || quantity != 1 || star != 0) {
|
||||
return "", fmt.Errorf("CW end does not accept arguments")
|
||||
}
|
||||
var nonce [16]byte
|
||||
if _, err := rand.Read(nonce[:]); err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
// and battle duration measurement continue to use the host's real clock.
|
||||
type GameClockState struct {
|
||||
OffsetMS int64 `json:"offset_ms"`
|
||||
WindowBeginMS int64 `json:"window_begin_ms,omitempty"`
|
||||
WindowEndMS int64 `json:"window_end_ms,omitempty"`
|
||||
}
|
||||
|
||||
func gameClockPath() (string, error) {
|
||||
@@ -41,6 +43,9 @@ func ReadGameClock() (GameClockState, error) {
|
||||
if err := json.Unmarshal(raw, &state); err != nil {
|
||||
return state, fmt.Errorf("game clock state: %w", err)
|
||||
}
|
||||
if (state.WindowBeginMS != 0 || state.WindowEndMS != 0) && (state.WindowBeginMS < 0 || state.WindowEndMS <= state.WindowBeginMS || state.WindowEndMS > 253402300799999) {
|
||||
return state, fmt.Errorf("invalid game clock window")
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
@@ -54,17 +59,18 @@ func GameTime() (time.Time, error) {
|
||||
return time.Time{}, fmt.Errorf("game clock outside supported calendar range")
|
||||
}
|
||||
ms := base + s.OffsetMS
|
||||
if s.WindowEndMS != 0 {
|
||||
span := s.WindowEndMS - s.WindowBeginMS
|
||||
delta := (ms - s.WindowBeginMS) % span
|
||||
if delta < 0 {
|
||||
delta += span
|
||||
}
|
||||
ms = s.WindowBeginMS + delta
|
||||
}
|
||||
return time.UnixMilli(ms), nil
|
||||
}
|
||||
|
||||
func SetGameTime(target *time.Time) (time.Time, error) {
|
||||
path, err := gameClockPath()
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
if path == "" {
|
||||
return time.Time{}, fmt.Errorf("configure game_clock.state_file or GAC_GAME_CLOCK_STATE_FILE first")
|
||||
}
|
||||
now := time.Now()
|
||||
state := GameClockState{}
|
||||
if target != nil {
|
||||
@@ -73,32 +79,62 @@ func SetGameTime(target *time.Time) (time.Time, error) {
|
||||
}
|
||||
state.OffsetMS = target.UnixMilli() - now.UnixMilli()
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
if err := saveGameClockState(state); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return time.UnixMilli(now.UnixMilli() + state.OffsetMS), nil
|
||||
}
|
||||
|
||||
// SetGameTimeWindow keeps the shared game calendar inside an observed Client
|
||||
// interval. It wraps at its end; the host clock and operational timers remain real.
|
||||
func SetGameTimeWindow(begin, end time.Time) (time.Time, error) {
|
||||
start, finish := begin.UnixMilli(), end.UnixMilli()
|
||||
if start < 0 || finish <= start || finish > 253402300799999 {
|
||||
return time.Time{}, fmt.Errorf("invalid game clock window")
|
||||
}
|
||||
now := time.Now()
|
||||
midpoint := start + (finish-start)/2
|
||||
state := GameClockState{OffsetMS: midpoint - now.UnixMilli(), WindowBeginMS: start, WindowEndMS: finish}
|
||||
if err := saveGameClockState(state); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return time.UnixMilli(midpoint), nil
|
||||
}
|
||||
|
||||
func saveGameClockState(state GameClockState) error {
|
||||
path, err := gameClockPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if path == "" {
|
||||
return fmt.Errorf("configure game_clock.state_file or GAC_GAME_CLOCK_STATE_FILE first")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
return err
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".game-clock-*.tmp")
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
return err
|
||||
}
|
||||
name := f.Name()
|
||||
defer os.Remove(name)
|
||||
if _, err = f.Write(raw); err != nil {
|
||||
f.Close()
|
||||
return time.Time{}, err
|
||||
return err
|
||||
}
|
||||
if err = f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return time.Time{}, err
|
||||
return err
|
||||
}
|
||||
if err = f.Close(); err != nil {
|
||||
return time.Time{}, err
|
||||
return err
|
||||
}
|
||||
if err = os.Rename(name, path); err != nil {
|
||||
return time.Time{}, err
|
||||
return err
|
||||
}
|
||||
return time.UnixMilli(now.UnixMilli() + state.OffsetMS), nil
|
||||
return nil
|
||||
}
|
||||
@@ -42,6 +42,36 @@ func TestGameClockSharedStateAndReset(t *testing.T) {
|
||||
if string(before) != string(after) {
|
||||
t.Fatal("read rewrote state")
|
||||
}
|
||||
begin := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := begin.Add(24 * time.Hour)
|
||||
if _, err := SetGameTimeWindow(begin, end); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
window, err := ReadGameClock()
|
||||
if err != nil || window.WindowBeginMS != begin.UnixMilli() || window.WindowEndMS != end.UnixMilli() {
|
||||
t.Fatalf("window not saved: %+v %v", window, err)
|
||||
}
|
||||
// Simulate the clock having advanced beyond the end without waiting a day.
|
||||
window.OffsetMS = end.Add(time.Hour).UnixMilli() - time.Now().UnixMilli()
|
||||
raw, _ = json.Marshal(window)
|
||||
if err := os.WriteFile(cfg.GameClock.StateFile, raw, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now, err = GameTime()
|
||||
if err != nil || now.Sub(begin.Add(time.Hour)) < 0 || now.Sub(begin.Add(time.Hour)) > time.Second {
|
||||
t.Fatalf("window did not wrap: %v %v", now, err)
|
||||
}
|
||||
before, _ = os.ReadFile(cfg.GameClock.StateFile)
|
||||
if _, err := GameTime(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ = os.ReadFile(cfg.GameClock.StateFile)
|
||||
if string(before) != string(after) {
|
||||
t.Fatal("window read rewrote state")
|
||||
}
|
||||
if _, err := SetGameTimeWindow(end, begin); err == nil {
|
||||
t.Fatal("invalid window accepted")
|
||||
}
|
||||
if _, err := SetGameTime(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -49,6 +79,10 @@ func TestGameClockSharedStateAndReset(t *testing.T) {
|
||||
if err != nil || time.Since(now).Abs() > time.Second {
|
||||
t.Fatalf("real time not restored: %v %v", now, err)
|
||||
}
|
||||
window, err = ReadGameClock()
|
||||
if err != nil || window.WindowBeginMS != 0 || window.WindowEndMS != 0 {
|
||||
t.Fatalf("reset retained window: %+v %v", window, err)
|
||||
}
|
||||
if err := os.WriteFile(cfg.GameClock.StateFile, []byte(`{"offset_ms":9223372036854775807}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -157,6 +157,59 @@ func SaveAvatarRank(id, rank uint32) error {
|
||||
return SaveAvatarProgress(id, nil, &rank)
|
||||
}
|
||||
|
||||
// MaxAvatarProgress updates the latest roster under the shared save lock,
|
||||
// preserving unknown fields and all non-avatar data.
|
||||
func MaxAvatarProgress() (int, error) {
|
||||
persistentWriteMu.Lock()
|
||||
defer persistentWriteMu.Unlock()
|
||||
unlock, err := lockPersistentFile()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer unlock()
|
||||
raw, err := os.ReadFile("freesr-data.json")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var root map[string]json.RawMessage
|
||||
if err = json.Unmarshal(raw, &root); err != nil || root == nil {
|
||||
return 0, fmt.Errorf("invalid freesr-data object")
|
||||
}
|
||||
var avatars map[string]json.RawMessage
|
||||
if err = json.Unmarshal(root["avatars"], &avatars); err != nil || avatars == nil {
|
||||
return 0, fmt.Errorf("invalid avatar roster")
|
||||
}
|
||||
for id, entry := range avatars {
|
||||
var avatar map[string]json.RawMessage
|
||||
if err = json.Unmarshal(entry, &avatar); err != nil || avatar == nil {
|
||||
return 0, fmt.Errorf("invalid avatar %s", id)
|
||||
}
|
||||
var data map[string]json.RawMessage
|
||||
if err = json.Unmarshal(avatar["data"], &data); err != nil || data == nil {
|
||||
return 0, fmt.Errorf("invalid rank data for avatar %s", id)
|
||||
}
|
||||
avatar["level"] = json.RawMessage("80")
|
||||
avatar["promotion"] = json.RawMessage("6")
|
||||
data["rank"] = json.RawMessage("6")
|
||||
avatar["data"], err = json.Marshal(data)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
avatars[id], err = json.Marshal(avatar)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
root["avatars"], err = json.Marshal(avatars)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err = writeJSONAtomic("freesr-data.json", root); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(avatars), nil
|
||||
}
|
||||
|
||||
// SaveAvatarProgress patches only the selected level and/or rank in the latest
|
||||
// import, under the same cross-process lock used by inventory and gacha saves.
|
||||
func SaveAvatarProgress(id uint32, level, rank *uint32) error {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
// Inspect actual battle buffs, rather than ChallengeStageID or a reused stage ID.
|
||||
// This covers an imported custom battle that already includes Corruption.
|
||||
func (r *battleRules) applyAhaHelp(battle *pb.SceneBattleInfo, legacyEnabled bool, owner uint32) error {
|
||||
func (r *battleRules) applyAhaHelp(battle *pb.SceneBattleInfo, legacyEnabled bool, owner uint32, attackerSlot *uint32) error {
|
||||
var corruption uint32
|
||||
for _, buff := range battle.BuffList {
|
||||
for _, id := range r.InvasionBuffs {
|
||||
@@ -57,7 +57,11 @@ func (r *battleRules) applyAhaHelp(battle *pb.SceneBattleInfo, legacyEnabled boo
|
||||
}
|
||||
battle.BuffList = filtered
|
||||
if support != 0 {
|
||||
battle.BuffList = append(battle.BuffList, battleBuff(support, owner, map[string]float32{"SkillIndex": 1}))
|
||||
buff := battleBuff(support, owner, map[string]float32{"SkillIndex": 1})
|
||||
if attackerSlot != nil {
|
||||
buff.TargetIndexList = []uint32{*attackerSlot}
|
||||
}
|
||||
battle.BuffList = append(battle.BuffList, buff)
|
||||
}
|
||||
SceneDebugLog("AHA_HELP stage=%d corruption=%d selected=%v legacy=%t support=%d level=%d", battle.StageId, corruption, selectedValue(selected), legacyEnabled, support, level)
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package gameserver
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"gac-hkrpg-go/internal/common"
|
||||
"gac-hkrpg-go/internal/testutil"
|
||||
pb "gac-hkrpg-go/protocol/proto"
|
||||
)
|
||||
|
||||
func TestAhaBuffTargetsSceneAttacker(t *testing.T) {
|
||||
temporary := t.TempDir()
|
||||
if err := testutil.CopyRuntime(testWorkspaceRoot, temporary); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Chdir(temporary)
|
||||
t.Setenv("GAC_CONFIG", "server.json")
|
||||
|
||||
rules, err := loadBattleRules()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resources, err := common.GameRes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
world, err := loadWorldBattleResources()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
levels, err := common.LoadAhaHelpLevels()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := common.SaveAhaHelpLevel(&levels[0].BuffLevel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Pick valid characters and a battle source from the checked-in resources;
|
||||
// neither the user's live roster nor their persistent settings are changed.
|
||||
var ids []uint32
|
||||
for id, config := range resources.AvatarConfigs {
|
||||
if id != 8001 && id != 1001 && rules.EntryAttackBuffs[config.DamageType] != 0 {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
if len(ids) < 4 || len(world.Cocoons) == 0 || len(world.NPCMonsters) == 0 {
|
||||
t.Fatal("battle fixture data incomplete")
|
||||
}
|
||||
player := &common.FreesrData{Avatars: map[uint32]*common.AvatarJson{}, Lineups: map[uint32]uint32{}}
|
||||
for i, id := range ids[:4] {
|
||||
player.Avatars[id] = &common.AvatarJson{AvatarID: id, Level: 1}
|
||||
player.Lineups[uint32(i)] = id
|
||||
}
|
||||
enabled := true
|
||||
player.EnableAhaGlobal, player.EnableSWGlobal, player.EnableCastoriceGlobal = &enabled, &enabled, &enabled
|
||||
var monster, corruption, ahaID uint32
|
||||
for id := range world.NPCMonsters {
|
||||
monster = id
|
||||
break
|
||||
}
|
||||
for _, id := range rules.InvasionBuffs {
|
||||
corruption = id
|
||||
break
|
||||
}
|
||||
for _, rule := range rules.GlobalPassives {
|
||||
if rule.Setting == "enable_aha_global" {
|
||||
ahaID = rule.BuffID
|
||||
}
|
||||
}
|
||||
if monster == 0 || corruption == 0 || ahaID == 0 {
|
||||
t.Fatal("battle fixture rule missing")
|
||||
}
|
||||
source := &common.BattleConfig{StageID: world.Cocoons[0].StageID, Monsters: [][]common.Monster{{{MonsterID: monster, Level: 1}}}, Blessings: []common.BattleBuffJson{{ID: corruption, Level: 1}}}
|
||||
session := &Session{MonsterEntities: map[uint32]uint32{777777: monster}}
|
||||
session.SetJSON(player)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
caster, want uint32
|
||||
}{
|
||||
{"first slot", 1, 0}, {"second slot", 2, 1}, {"third slot", 3, 2}, {"fourth slot", 4, 3},
|
||||
{"avatar entity namespace", techniqueAvatarEntityOffset + ids[2], 2},
|
||||
{"direct entry has no caster", 0, 0}, {"unknown actor", 12345, 0}, {"monster ambush", 777777, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
battle, err := createBattleInfo(session, tc.caster, 0, source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var aha, help *pb.BattleBuff
|
||||
for _, buff := range battle.BuffList {
|
||||
switch buff.Id {
|
||||
case ahaID:
|
||||
aha = buff
|
||||
case levels[0].BuffID:
|
||||
help = buff
|
||||
default:
|
||||
for _, rule := range rules.GlobalPassives {
|
||||
if rule.Setting != "enable_aha_global" && buff.Id == rule.BuffID && (len(buff.TargetIndexList) != 1 || buff.TargetIndexList[0] != 0) {
|
||||
t.Fatal("unrelated passive target changed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for name, buff := range map[string]*pb.BattleBuff{"Aha Global": aha, "Aha Help": help} {
|
||||
if buff == nil || len(buff.TargetIndexList) != 1 || buff.TargetIndexList[0] != tc.want {
|
||||
t.Fatalf("%s target: got %v, want slot %d", name, buff, tc.want)
|
||||
}
|
||||
}
|
||||
// Source ownership and unrelated corruption are unchanged.
|
||||
if help.OwnerId != battle.BattleAvatarList[0].Index {
|
||||
t.Fatal("Aha Help owner changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
off := uint32(0)
|
||||
if err := common.SaveAhaHelpLevel(&off); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
battle, err := createBattleInfo(session, 2, 0, source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, buff := range battle.BuffList {
|
||||
if buff.Id == levels[0].BuffID {
|
||||
t.Fatal("disabled Aha Help was added")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func (r *battleRules) techniqueBuffs(a *common.AvatarJson, slot, leader uint32,
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *battleRules) addGlobalPassives(battle *goproto.SceneBattleInfo, player *common.FreesrData) {
|
||||
func (r *battleRules) addGlobalPassives(battle *goproto.SceneBattleInfo, player *common.FreesrData, attackerSlot *uint32) {
|
||||
for _, rule := range r.GlobalPassives {
|
||||
var enabled *bool
|
||||
switch rule.Setting {
|
||||
@@ -170,7 +170,15 @@ func (r *battleRules) addGlobalPassives(battle *goproto.SceneBattleInfo, player
|
||||
if !present {
|
||||
owner = battle.BattleAvatarList[0].Index
|
||||
}
|
||||
battle.BuffList = append(battle.BuffList, battleBuff(rule.BuffID, owner, nil))
|
||||
buff := battleBuff(rule.BuffID, owner, nil)
|
||||
// Owner is the configured source of the passive; the target is the
|
||||
// scene actor who started this battle. Other global passives retain
|
||||
// their captured target. A request without an identified attacker
|
||||
// keeps the existing wire representation.
|
||||
if rule.Setting == "enable_aha_global" && attackerSlot != nil {
|
||||
buff.TargetIndexList = []uint32{*attackerSlot}
|
||||
}
|
||||
battle.BuffList = append(battle.BuffList, buff)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ func cwArchiveSnapshot(c *common.CurrencyWarsCatalog) (*common.CurrencyWarsSnaps
|
||||
return common.ReadCurrencyWarsSnapshot(cfg.PlayerUID, c.ClientVersion, c.Provenance.GameAssemblySHA256)
|
||||
}
|
||||
|
||||
func (s *Session) saveCurrencyWarsTerminal(data *pb.GridFightGetDataScRsp, c *common.CurrencyWarsCatalog, record *pb.OEDHMAHDECC) error {
|
||||
func (s *Session) saveCurrencyWarsTerminal(data *pb.GridFightGetDataScRsp, c *common.CurrencyWarsCatalog, record *pb.OEDHMAHDECC, commandID ...string) error {
|
||||
if record == nil || s.currencyWarsRevision >= math.MaxUint32 {
|
||||
return fmt.Errorf("CW archive record/identifier invalid")
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func (s *Session) saveCurrencyWarsTerminal(data *pb.GridFightGetDataScRsp, c *co
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.saveCurrencyWarsSnapshot(data, c, nil, wire)
|
||||
return s.saveCurrencyWarsSnapshot(data, c, nil, wire, commandID...)
|
||||
}
|
||||
|
||||
func cwReadArchives(snapshot *common.CurrencyWarsSnapshot) ([]*pb.FAIHLAKPABN, error) {
|
||||
|
||||
@@ -6,9 +6,6 @@ import (
|
||||
)
|
||||
|
||||
func (s *Session) applyCurrencyWarsCommands() error {
|
||||
if !s.currencyWarsOpen {
|
||||
return nil
|
||||
}
|
||||
if _, active := CURRENT_BATTLE.Load(s.Token); active {
|
||||
return nil
|
||||
}
|
||||
@@ -23,10 +20,20 @@ func (s *Session) applyCurrencyWarsCommands() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A pending grant can be blocked by a stuck page. Give the explicit quit
|
||||
// request priority so it can recover that run without applying the grant.
|
||||
for _, command := range commands {
|
||||
if command.UID == cfg.PlayerUID && command.Field == common.CurrencyWarsCommandEnd {
|
||||
return s.applyCurrencyWarsEnd(command)
|
||||
}
|
||||
}
|
||||
for _, command := range commands {
|
||||
if command.UID != cfg.PlayerUID {
|
||||
continue
|
||||
}
|
||||
if !s.currencyWarsOpen {
|
||||
continue
|
||||
}
|
||||
catalog, data, err := cwPreparingData(s)
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -62,3 +69,52 @@ func (s *Session) applyCurrencyWarsCommands() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// End is allowed even with a pending choice so it can recover a stuck CW UI.
|
||||
// Do not end a battle or replace a newer session's saved data. Queue removal,
|
||||
// archive and quit settlement share the same persistent CAS transaction.
|
||||
func (s *Session) applyCurrencyWarsEnd(command common.CurrencyWarsCommand) error {
|
||||
catalog, err := common.LoadCurrencyWarsCatalog()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, revision, err := readCurrencyWarsData(catalog)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data == nil || data.DHFOFLFCLAH == nil || data.DHFOFLFCLAH.UniqueId != command.RunID {
|
||||
if err := common.AcknowledgeCurrencyWarsCommand(command.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
PacketLog(fmt.Sprintf("CW_GM discarded end request=%s: run already ended or changed", command.ID))
|
||||
return nil
|
||||
}
|
||||
if revision != s.currencyWarsRevision {
|
||||
// If this session has not opened CW, it holds no CW UI state. Adopt the
|
||||
// freshly read revision; the CAS still rejects any concurrent changes.
|
||||
if s.currencyWarsOpen {
|
||||
return common.ErrCurrencyWarsSaveConflict
|
||||
}
|
||||
s.currencyWarsRevision = revision
|
||||
}
|
||||
keep, settle, err := currencyWarsQuitSettlement(catalog, data, data.DHFOFLFCLAH)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data.DHFOFLFCLAH = nil
|
||||
if err := s.saveCurrencyWarsTerminal(data, catalog, settle.BFLLJJBHLDF, command.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
wasOpen := s.currencyWarsOpen
|
||||
s.currencyWarsOpen = false
|
||||
if wasOpen {
|
||||
if err := cwSend(s, keep); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cwSend(s, settle); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
PacketLog(fmt.Sprintf("CW_GM applied end request=%s run=%d revision=%d result=quit", command.ID, command.RunID, s.currencyWarsRevision))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package gameserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gac-hkrpg-go/internal/common"
|
||||
pb "gac-hkrpg-go/protocol/proto"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func cwEndFixture(t *testing.T) (*common.CurrencyWarsCatalog, *pb.GridFightGetDataScRsp, *Session, uint32) {
|
||||
t.Helper()
|
||||
c := isolatedCWTest(t)
|
||||
data, err := newCurrencyWarsData(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := &pb.GridFightStartGamePlayCsReq{DivisionId: c.CapturedStages[0].ID, LJNGPCKPJLF: c.ActiveSeasonID}
|
||||
// Select a division actually present in the captured initial-run resource.
|
||||
raw, err := common.ReadResourceFile("resources/CurrencyWarsInitialRuns.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var resource struct {
|
||||
Entries []struct {
|
||||
Division uint32 `json:"division_id"`
|
||||
Overclock bool `json:"is_overclock"`
|
||||
} `json:"entries"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &resource); err != nil || len(resource.Entries) == 0 {
|
||||
t.Fatalf("initial runs: %v", err)
|
||||
}
|
||||
request.DivisionId, request.IsOverlock = resource.Entries[0].Division, resource.Entries[0].Overclock
|
||||
run, err := buildCurrencyWarsInitialRun(c, request, 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
offer := run.FMIGOKIDKJL.GetMFFCPLBNHCF()
|
||||
if offer == nil || len(offer.NPHOIMEEDHM) == 0 {
|
||||
t.Fatal("portal offer missing")
|
||||
}
|
||||
run, _, err = currencyWarsEntryChoice(c, run, run.FMIGOKIDKJL.QueuePosition, offer.NPHOIMEEDHM[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data.DHFOFLFCLAH = run
|
||||
s := &Session{Token: 444, currencyWarsOpen: true}
|
||||
if err := s.saveCurrencyWarsData(data, c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := common.LoadRuntimeConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c, data, s, cfg.PlayerUID
|
||||
}
|
||||
|
||||
func TestCurrencyWarsEndWaitsForBattleAndSettlesPendingRun(t *testing.T) {
|
||||
c, data, s, uid := cwEndFixture(t)
|
||||
runID := data.DHFOFLFCLAH.UniqueId
|
||||
if data.DHFOFLFCLAH.FMIGOKIDKJL == nil {
|
||||
t.Fatal("fixture must have a pending choice")
|
||||
}
|
||||
if err := common.PatchPersistentFields(map[string]json.RawMessage{"ex_ranks_unlocked": json.RawMessage(`{"1204":false}`), "unrelated": json.RawMessage(`{"keep":true}`)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// An ordinary grant queued first must not prevent ending a stuck page.
|
||||
if _, err := common.QueueCurrencyWarsCommand(uid, runID, "gold", 99); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
endID, err := common.QueueCurrencyWarsCommand(uid, runID, common.CurrencyWarsCommandEnd, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := os.ReadFile("persistent")
|
||||
CURRENT_BATTLE.Store(s.Token, &pb.SceneBattleInfo{})
|
||||
if err := s.applyCurrencyWarsCommands(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
CURRENT_BATTLE.Delete(s.Token)
|
||||
after, _ := os.ReadFile("persistent")
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("end ran while Battle was active")
|
||||
}
|
||||
if err := s.applyCurrencyWarsCommands(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved, _, err := readCurrencyWarsData(c)
|
||||
if err != nil || saved.DHFOFLFCLAH != nil {
|
||||
t.Fatalf("run not ended: %v", err)
|
||||
}
|
||||
snapshot, err := cwArchiveSnapshot(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archives, err := cwReadArchives(snapshot)
|
||||
if err != nil || len(archives) != 1 {
|
||||
t.Fatalf("archive: %v", err)
|
||||
}
|
||||
if archives[0].BFLLJJBHLDF.Reason != pb.PEIJNEFOCHI_PEIJNEFOCHI_NOCIJDINKPJ {
|
||||
t.Fatal("end must settle as quit")
|
||||
}
|
||||
queued, err := common.ReadCurrencyWarsCommands()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, cmd := range queued {
|
||||
if cmd.ID == endID {
|
||||
t.Fatal("end was not atomically acknowledged")
|
||||
}
|
||||
}
|
||||
var root map[string]json.RawMessage
|
||||
raw, _ := os.ReadFile("persistent")
|
||||
if err := json.Unmarshal(raw, &root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ex, unrelated map[string]bool
|
||||
if err := json.Unmarshal(root["ex_ranks_unlocked"], &ex); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(root["unrelated"], &unrelated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ex) != 1 || ex["1204"] || !unrelated["keep"] || len(unrelated) != 1 {
|
||||
t.Fatal("unrelated state changed")
|
||||
}
|
||||
if err := s.applyCurrencyWarsCommands(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := cwArchiveSnapshot(c)
|
||||
if err != nil || len(again.Archives) != 1 {
|
||||
t.Fatal("duplicate settlement")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrencyWarsEndRejectsStaleAndDifferentRun(t *testing.T) {
|
||||
c, data, s, uid := cwEndFixture(t)
|
||||
runID := data.DHFOFLFCLAH.UniqueId
|
||||
cmdID, err := common.QueueCurrencyWarsCommand(uid, runID, common.CurrencyWarsCommandEnd, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.currencyWarsRevision--
|
||||
before, _ := os.ReadFile("persistent")
|
||||
if err := s.applyCurrencyWarsCommands(); !errors.Is(err, common.ErrCurrencyWarsSaveConflict) {
|
||||
t.Fatalf("stale open session: %v", err)
|
||||
}
|
||||
after, _ := os.ReadFile("persistent")
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("stale session changed save")
|
||||
}
|
||||
if err := common.AcknowledgeCurrencyWarsCommand(cmdID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := common.QueueCurrencyWarsCommand(uid, runID+1, common.CurrencyWarsCommandEnd, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.applyCurrencyWarsCommands(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved, _, err := readCurrencyWarsData(c)
|
||||
if err != nil || saved.DHFOFLFCLAH == nil || saved.DHFOFLFCLAH.UniqueId != runID {
|
||||
t.Fatal("wrong-run command ended active run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrencyWarsEndWorksAfterLeavingCW(t *testing.T) {
|
||||
c, data, s, uid := cwEndFixture(t)
|
||||
if _, err := common.QueueCurrencyWarsCommand(uid, data.DHFOFLFCLAH.UniqueId, common.CurrencyWarsCommandEnd, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.currencyWarsOpen = false
|
||||
s.currencyWarsRevision = 0 // A newly connected session has not read CW yet.
|
||||
if err := s.applyCurrencyWarsCommands(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved, _, err := readCurrencyWarsData(c)
|
||||
if err != nil || saved.DHFOFLFCLAH != nil {
|
||||
t.Fatal("closed CW did not end")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrencyWarsEndNotifiesOpenClient(t *testing.T) {
|
||||
_, data, savedSession, uid := cwEndFixture(t)
|
||||
s, receive := replySession(t)
|
||||
s.currencyWarsRevision = savedSession.currencyWarsRevision
|
||||
s.currencyWarsOpen = true
|
||||
if _, err := common.QueueCurrencyWarsCommand(uid, data.DHFOFLFCLAH.UniqueId, common.CurrencyWarsCommandEnd, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.applyCurrencyWarsCommands(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
receive(&pb.GridFightSyncKeepWinCntNotify{})
|
||||
settlement := &pb.GridFightSettleNotify{}
|
||||
receive(settlement)
|
||||
if settlement.BFLLJJBHLDF == nil || settlement.BFLLJJBHLDF.Reason != pb.PEIJNEFOCHI_PEIJNEFOCHI_NOCIJDINKPJ {
|
||||
t.Fatal("client did not receive quit settlement")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrencyWarsEndDoesNotCommitIfCommandIsMissing(t *testing.T) {
|
||||
_, data, s, uid := cwEndFixture(t)
|
||||
before, _ := os.ReadFile("persistent")
|
||||
cmd := common.CurrencyWarsCommand{ID: "missing", UID: uid, RunID: data.DHFOFLFCLAH.UniqueId, Field: common.CurrencyWarsCommandEnd}
|
||||
if err := s.applyCurrencyWarsEnd(cmd); err == nil {
|
||||
t.Fatal("missing command should reject settlement")
|
||||
}
|
||||
after, _ := os.ReadFile("persistent")
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("failed acknowledgement committed settlement or archive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrencyWarsOpenStateTracksStartLeaveAndResume(t *testing.T) {
|
||||
_, data, writer, uid := cwEndFixture(t)
|
||||
previous := data.DHFOFLFCLAH
|
||||
if _, err := common.QueueCurrencyWarsCommand(uid, previous.UniqueId, common.CurrencyWarsCommandEnd, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writer.currencyWarsOpen = false
|
||||
if err := writer.applyCurrencyWarsCommands(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, receive := replySession(t)
|
||||
s.currencyWarsRevision = writer.currencyWarsRevision
|
||||
req := &pb.GridFightStartGamePlayCsReq{DivisionId: previous.DivisionId, IsOverlock: previous.IsOverlock, LJNGPCKPJLF: previous.LJNGPCKPJLF}
|
||||
callReviewRequest(t, s, req)
|
||||
start := &pb.GridFightStartGamePlayScRsp{}
|
||||
receive(start)
|
||||
if start.Retcode != 0 || start.DHFOFLFCLAH == nil || !s.currencyWarsOpen {
|
||||
t.Fatal("successful start did not mark CW open")
|
||||
}
|
||||
callReviewRequest(t, s, &pb.GridFightQuitLeaveGamePlayCsReq{})
|
||||
receive(&pb.GridFightQuitLeaveGamePlayScRsp{})
|
||||
if s.currencyWarsOpen {
|
||||
t.Fatal("leave did not close CW")
|
||||
}
|
||||
callReviewRequest(t, s, &pb.GridFightResumeGamePlayCsReq{UniqueId: start.DHFOFLFCLAH.UniqueId})
|
||||
resume := &pb.GridFightResumeGamePlayScRsp{}
|
||||
receive(resume)
|
||||
if resume.Retcode != 0 || !s.currencyWarsOpen {
|
||||
t.Fatal("successful resume did not mark CW open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrencyWarsInitialRoundReservesHigherStarStarter(t *testing.T) {
|
||||
c, data, _, _ := cwEndFixture(t)
|
||||
run := data.DHFOFLFCLAH
|
||||
_, team, shop := cwComponents(run)
|
||||
if team == nil || shop == nil || len(team.GridGameRoleList) == 0 {
|
||||
t.Fatal("starter team fixture missing")
|
||||
}
|
||||
pool, err := c.NewCapturedCardPool()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var selected common.CurrencyWarsRoleVariant
|
||||
for _, v := range c.CapturedVariants {
|
||||
base, err := c.CapturedRole(v.Config.ID)
|
||||
if err != nil || v.Star <= base.DefaultStar {
|
||||
continue
|
||||
}
|
||||
if _, present := pool.Roles[v.Config.ID]; present {
|
||||
selected = v
|
||||
break
|
||||
}
|
||||
}
|
||||
if selected.Config.ID == 0 {
|
||||
t.Fatal("higher-star pool role missing")
|
||||
}
|
||||
// The live failure occurs after a portal has supplied a non-default star.
|
||||
team.GridGameRoleList = []*pb.CDEAABODKCP{{Id: selected.Config.ID, RoleStar: selected.Star, UniqueId: 1, Pos: 1}}
|
||||
if run.FMIGOKIDKJL.GetNNDGEJFLGGF() == nil || len(shop.OGDKJPDFFAI) != 0 {
|
||||
t.Fatal("fixture is not at initial round")
|
||||
}
|
||||
invalid := proto.Clone(run).(*pb.MPIMAEMCEFH)
|
||||
_, invalidTeam, _ := cwComponents(invalid)
|
||||
invalidTeam.GridGameRoleList[0].RoleStar = ^uint32(0)
|
||||
before := proto.Clone(invalid)
|
||||
if _, err := cwInitialRound(c, invalid); err == nil || !proto.Equal(before, invalid) {
|
||||
t.Fatal("invalid starter star was accepted or changed state")
|
||||
}
|
||||
if _, err := cwInitialRound(c, run); err != nil {
|
||||
t.Fatalf("higher-star starter rejected: %v", err)
|
||||
}
|
||||
if run.FMIGOKIDKJL.GetLGNPHHBGKFH() == nil || len(shop.OGDKJPDFFAI) == 0 {
|
||||
t.Fatal("initial round did not advance to preparation/shop")
|
||||
}
|
||||
}
|
||||
@@ -79,19 +79,20 @@ func cwInitialRound(catalog *common.CurrencyWarsCatalog, run *pb.MPIMAEMCEFH) (*
|
||||
}
|
||||
// Reserve starter copies from the same finite pool before drawing the shop.
|
||||
for _, role := range team.GridGameRoleList {
|
||||
r, ok := pool.Roles[role.Id]
|
||||
if !ok {
|
||||
if role == nil {
|
||||
return nil, fmt.Errorf("CW starter role is missing")
|
||||
}
|
||||
if _, ok := pool.Roles[role.Id]; !ok {
|
||||
continue
|
||||
}
|
||||
config, e := catalog.CapturedRole(role.Id)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
if _, err := catalog.CapturedRoleVariant(role.Id, role.RoleStar); err != nil {
|
||||
return nil, fmt.Errorf("CW starter role %d star %d not captured: %w", role.Id, role.RoleStar, err)
|
||||
}
|
||||
if role.RoleStar != config.DefaultStar || r.Remaining == 0 {
|
||||
return nil, fmt.Errorf("CW starter pool reservation is not supported")
|
||||
// Reserve the number of base copies represented by this star, using
|
||||
// the same captured merge rules as later shop restocks.
|
||||
if err := pool.ReserveOwned(catalog, role.Id, role.RoleStar); err != nil {
|
||||
return nil, fmt.Errorf("CW starter role %d star %d: %w", role.Id, role.RoleStar, err)
|
||||
}
|
||||
r.Remaining--
|
||||
pool.Roles[role.Id] = r
|
||||
}
|
||||
level, err := catalog.PlayerLevel(player.EHNJDIDABHD)
|
||||
if err != nil {
|
||||
|
||||
@@ -164,7 +164,11 @@ func DispatchMessage(s *Session, cmdID uint16, payload []byte) {
|
||||
|
||||
// 1. empty-dummy responses
|
||||
if rspID, ok := dummyResponses[cmdID]; ok {
|
||||
_ = s.SendRaw(&NetPacket{CmdType: rspID})
|
||||
if err := s.SendRaw(&NetPacket{CmdType: rspID}); err != nil {
|
||||
Log(fmt.Sprintf("[DISPATCH] response %d (%s) failed: %v", rspID, goproto.NameByCmdID[rspID], err))
|
||||
} else {
|
||||
Log(fmt.Sprintf("[PACKET] queued empty response cmd_id: %d (%s)", rspID, goproto.NameByCmdID[rspID]))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -172,6 +176,7 @@ func DispatchMessage(s *Session, cmdID uint16, payload []byte) {
|
||||
if h, ok := rawHandlers[cmdID]; ok {
|
||||
if err := h(s, payload); err != nil {
|
||||
PacketLog("[ERROR] raw handler " + fmt.Sprint(cmdID) + ": " + err.Error())
|
||||
Log(fmt.Sprintf("[DISPATCH] raw handler %d failed: %v", cmdID, err))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -180,6 +185,7 @@ func DispatchMessage(s *Session, cmdID uint16, payload []byte) {
|
||||
if h, ok := typedHandlers[cmdID]; ok {
|
||||
if err := h(s, payload); err != nil {
|
||||
PacketLog("[ERROR] handler cmd " + fmt.Sprint(cmdID) + ": " + err.Error())
|
||||
Log(fmt.Sprintf("[DISPATCH] handler %d failed: %v", cmdID, err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ func (g *Gateway) Listen() error {
|
||||
if n == 20 {
|
||||
if err := g.processNetOperation(append([]byte(nil), buf[:n]...), addr); err != nil {
|
||||
PacketLog("[ERROR] netop: " + err.Error())
|
||||
Log("[KCP] handshake rejected: " + err.Error())
|
||||
}
|
||||
} else if n >= 28 {
|
||||
g.processKCPPayload(append([]byte(nil), buf[:n]...), addr)
|
||||
@@ -208,6 +209,7 @@ func (g *Gateway) processKCPPayload(data []byte, addr *net.UDPAddr) {
|
||||
// same-conv replays from a stale client get rejected by the token check
|
||||
if err := session.Consume(data); err != nil {
|
||||
PacketLog("[ERROR] Session consume error (" + addr.String() + "): " + err.Error())
|
||||
Log("[KCP] packet rejected: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -457,9 +457,12 @@ func createBattleInfo(s *Session, casterID, skillIndex uint32, sources ...*commo
|
||||
// actor from the scene's actual slot IDs, rather than assuming cast_entity
|
||||
// identifies a lineup position.
|
||||
attackSlot, attackID := firstAvatarIdx, firstAvatarID
|
||||
var attackerSlot *uint32
|
||||
_, attackedByMonster := s.MonsterEntities[casterID]
|
||||
for _, avatar := range battleInfo.BattleAvatarList {
|
||||
if casterID != 0 && avatar.Index+1 == casterID {
|
||||
if !attackedByMonster && casterID != 0 && (avatar.Index+1 == casterID || (casterID >= techniqueAvatarEntityOffset && avatar.Id == casterID-techniqueAvatarEntityOffset)) {
|
||||
attackSlot, attackID = avatar.Index, avatar.Id
|
||||
attackerSlot = &attackSlot
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -472,7 +475,6 @@ func createBattleInfo(s *Session, casterID, skillIndex uint32, sources ...*commo
|
||||
if attackBuffID == 0 {
|
||||
attackBuffID = cfg.WeaknessBuffID
|
||||
}
|
||||
_, attackedByMonster := s.MonsterEntities[casterID]
|
||||
if attackedByMonster {
|
||||
// An ambush has no player entry attack.
|
||||
} else if attackBuffID != 0 {
|
||||
@@ -550,7 +552,7 @@ func createBattleInfo(s *Session, casterID, skillIndex uint32, sources ...*commo
|
||||
battleInfo.MonsterWaveList = common.ToSceneMonsterWaves(player.BattleConfig.Monsters)
|
||||
battleInfo.JAOIOKBBMFC = uint32(len(battleInfo.MonsterWaveList))
|
||||
|
||||
rules.addGlobalPassives(battleInfo, player)
|
||||
rules.addGlobalPassives(battleInfo, player, attackerSlot)
|
||||
|
||||
// Official challenges add their resource-defined corruption. Custom battles
|
||||
// retain only the corruption actually supplied in their buff list.
|
||||
@@ -563,7 +565,7 @@ func createBattleInfo(s *Session, casterID, skillIndex uint32, sources ...*commo
|
||||
battleInfo.BuffList = append(battleInfo.BuffList, battleBuff(buffID, firstAvatarIdx, map[string]float32{"SkillIndex": 1}))
|
||||
}
|
||||
}
|
||||
if err := rules.applyAhaHelp(battleInfo, player.BattleConfig.EnableInvasionSupport, firstAvatarIdx); err != nil {
|
||||
if err := rules.applyAhaHelp(battleInfo, player.BattleConfig.EnableInvasionSupport, firstAvatarIdx, attackerSlot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(battleInfo.MonsterWaveList) == 0 {
|
||||
|
||||
@@ -95,6 +95,7 @@ func init() {
|
||||
return nil
|
||||
}
|
||||
rsp.DHFOFLFCLAH = proto.Clone(run).(*goproto.MPIMAEMCEFH)
|
||||
s.currencyWarsOpen = true
|
||||
rsp.Retcode = 0
|
||||
PacketLog(fmt.Sprintf("CW_START_GAMEPLAY started mode=%s division=%d season=%d run=%d revision=%d source=captured_initial_preset", mode, req.DivisionId, req.LJNGPCKPJLF, run.UniqueId, s.currencyWarsRevision))
|
||||
return nil
|
||||
@@ -159,6 +160,7 @@ func init() {
|
||||
}
|
||||
}
|
||||
}
|
||||
s.currencyWarsOpen = true
|
||||
rsp.Retcode = 0
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -77,9 +77,22 @@ func init() {
|
||||
if player == nil {
|
||||
return nil
|
||||
}
|
||||
// The imported banner lists target another Client. Reject draws until
|
||||
// their pools, grants and protocol fields are verified for this Client.
|
||||
mapped, _, err := common.LoadLimitedGachaBanners()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, banner := range mapped {
|
||||
if banner.GachaID == req.GachaId {
|
||||
return drawLimitedAvatar(s, req, rsp, banner)
|
||||
}
|
||||
}
|
||||
drawn, err := drawSandboxPool(s, req, rsp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !drawn {
|
||||
rsp.Retcode = uint32(pb.Retcode_RET_GACHA_NOT_SUPPORT)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
|
||||
@@ -25,12 +25,15 @@ func TestClientRelicCatalogFiltersUnsupportedInventoryEntries(t *testing.T) {
|
||||
{RelicID: validID, InternalUID: 1},
|
||||
{RelicID: 71011, InternalUID: 2},
|
||||
{RelicID: 63295, InternalUID: 3},
|
||||
{RelicID: 63296, InternalUID: 4},
|
||||
{RelicID: 63305, InternalUID: 5},
|
||||
{RelicID: 63306, InternalUID: 6},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Tid != validID || omitted != 2 {
|
||||
t.Fatalf("filtered Relics = %+v, omitted %d; want only Client-supported ID %d", got, omitted, validID)
|
||||
if len(got) != 5 || got[0].Tid != validID || omitted != 1 {
|
||||
t.Fatalf("filtered Relics = %+v, omitted %d; want five Client-supported IDs", got, omitted)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package gameserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCapturedPlanarRelicAffixesMatchCatalog(t *testing.T) {
|
||||
chdirRepositoryRoot(t)
|
||||
raw, err := os.ReadFile("resources/RelicClientCapture.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var capture struct {
|
||||
Relics []struct{ ID, SetID, MainAffixGroup, SubAffixGroup uint32 } `json:"relics"`
|
||||
Main []struct{ GroupID, AffixID, Property uint32 } `json:"main_affixes"`
|
||||
Sub []struct{ GroupID, AffixID, Property uint32 } `json:"sub_affixes"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &capture); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadInventoryItems(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(capture.Relics) != 4 {
|
||||
t.Fatalf("captured %d relics", len(capture.Relics))
|
||||
}
|
||||
for _, row := range capture.Relics {
|
||||
var found bool
|
||||
for _, item := range inventoryCatalog.Relics {
|
||||
if item.RelicID != row.ID {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if item.RelicSetID != row.SetID {
|
||||
t.Fatalf("relic %d has wrong set", row.ID)
|
||||
}
|
||||
var mainProperty uint32
|
||||
for _, main := range capture.Main {
|
||||
if main.GroupID == row.MainAffixGroup && main.AffixID == item.MainAffixID {
|
||||
mainProperty = main.Property
|
||||
}
|
||||
}
|
||||
if mainProperty == 0 {
|
||||
t.Fatalf("relic %d main affix is outside its group", row.ID)
|
||||
}
|
||||
seen := make(map[uint32]bool)
|
||||
for _, affix := range item.SubAffixes {
|
||||
valid := false
|
||||
for _, sub := range capture.Sub {
|
||||
if sub.GroupID == row.SubAffixGroup && sub.AffixID == affix.SubAffixID && sub.Property != mainProperty {
|
||||
valid = true
|
||||
}
|
||||
}
|
||||
if !valid || seen[affix.SubAffixID] {
|
||||
t.Fatalf("relic %d has invalid or duplicate sub affix %d", row.ID, affix.SubAffixID)
|
||||
}
|
||||
seen[affix.SubAffixID] = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("relic %d missing from catalog", row.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ type Session struct {
|
||||
|
||||
kcpMu sync.Mutex
|
||||
kcp *kcplib.KCP
|
||||
levelRewardAwaitingAck bool // guarded by kcpMu
|
||||
|
||||
startTime time.Time
|
||||
nextSceneSave int64
|
||||
@@ -129,6 +130,10 @@ func (s *Session) Consume(data []byte) error {
|
||||
s.kcpMu.Unlock()
|
||||
return err
|
||||
}
|
||||
if s.levelRewardAwaitingAck && s.kcp.PendingSends() == 0 {
|
||||
Log("[KCP] outstanding responses including level rewards acknowledged")
|
||||
s.levelRewardAwaitingAck = false
|
||||
}
|
||||
if err := s.kcp.Update(s.sessionTimeMs()); err != nil {
|
||||
s.kcpMu.Unlock()
|
||||
return err
|
||||
@@ -141,9 +146,13 @@ func (s *Session) Consume(data []byte) error {
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
pkt, err := DecodeNetPacket(buf[:n])
|
||||
// DecodeNetPacket returns slices backed by its input. Keep each frame
|
||||
// independent before the next Recv overwrites the shared buffer.
|
||||
frame := append([]byte(nil), buf[:n]...)
|
||||
pkt, err := DecodeNetPacket(frame)
|
||||
if err != nil {
|
||||
PacketLog(fmt.Sprintf("[ERROR] bad packet: %v", err))
|
||||
Log(fmt.Sprintf("[PACKET] invalid frame: %v", err))
|
||||
continue
|
||||
}
|
||||
packets = append(packets, pkt)
|
||||
@@ -213,7 +222,14 @@ func (s *Session) SendRaw(pkt *NetPacket) error {
|
||||
if _, err := s.kcp.Send(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.kcp.Flush()
|
||||
if err := s.kcp.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if pkt.CmdType == uint16(goproto.CmdIDByName["GetLevelRewardTakenListScRsp"]) {
|
||||
s.levelRewardAwaitingAck = s.kcp.PendingSends() > 0
|
||||
Log(fmt.Sprintf("[KCP] level rewards response queued; pending segments=%d", s.kcp.PendingSends()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DriveUpdate is called by the gateway ticker to run retransmissions.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package gameserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
|
||||
kcplib "gac-hkrpg-go/pkg/kcp"
|
||||
)
|
||||
|
||||
// A single UDP datagram can contain multiple complete KCP messages. Each
|
||||
// handler must receive its own payload even when the receive buffer is reused.
|
||||
func TestConsumeKeepsBatchedPacketBodiesIndependent(t *testing.T) {
|
||||
const firstID, secondID uint16 = 65000, 65001
|
||||
for _, id := range []uint16{firstID, secondID} {
|
||||
if _, ok := rawHandlers[id]; ok {
|
||||
t.Fatalf("test handler %d already registered", id)
|
||||
}
|
||||
if _, ok := typedHandlers[id]; ok {
|
||||
t.Fatalf("test handler %d already registered", id)
|
||||
}
|
||||
if _, ok := dummyResponses[id]; ok {
|
||||
t.Fatalf("test command %d has dummy response", id)
|
||||
}
|
||||
}
|
||||
got := map[uint16]string{}
|
||||
rawHandlers[firstID] = func(_ *Session, body []byte) error { got[firstID] = string(body); return nil }
|
||||
rawHandlers[secondID] = func(_ *Session, body []byte) error { got[secondID] = string(body); return nil }
|
||||
defer delete(rawHandlers, firstID)
|
||||
defer delete(rawHandlers, secondID)
|
||||
|
||||
const conv, token uint32 = 77, 88
|
||||
segment := func(seq uint32, frame []byte) []byte {
|
||||
out := make([]byte, kcplib.Overhead+len(frame))
|
||||
binary.LittleEndian.PutUint32(out[0:], conv)
|
||||
binary.LittleEndian.PutUint32(out[4:], token)
|
||||
out[8] = kcplib.CmdPush
|
||||
binary.LittleEndian.PutUint16(out[10:], 128)
|
||||
binary.LittleEndian.PutUint32(out[16:], seq)
|
||||
binary.LittleEndian.PutUint32(out[24:], uint32(len(frame)))
|
||||
copy(out[kcplib.Overhead:], frame)
|
||||
return out
|
||||
}
|
||||
first := (&NetPacket{CmdType: firstID, Body: []byte("first")}).Encode()
|
||||
second := (&NetPacket{CmdType: secondID, Body: []byte("other")}).Encode()
|
||||
datagram := append(segment(0, first), segment(1, second)...)
|
||||
s := NewSession(func([]byte) error { return nil }, conv, token)
|
||||
defer s.Close()
|
||||
if err := s.Consume(datagram); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got[firstID] != "first" || got[secondID] != "other" {
|
||||
t.Fatalf("batched payloads corrupted: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeOutOfOrderFragmentsAndFollowingPacket(t *testing.T) {
|
||||
const firstID, secondID uint16 = 65002, 65003
|
||||
for _, id := range []uint16{firstID, secondID} {
|
||||
if _, ok := rawHandlers[id]; ok {
|
||||
t.Fatalf("test handler %d already registered", id)
|
||||
}
|
||||
if _, ok := typedHandlers[id]; ok {
|
||||
t.Fatalf("test handler %d already registered", id)
|
||||
}
|
||||
if _, ok := dummyResponses[id]; ok {
|
||||
t.Fatalf("test command %d has dummy response", id)
|
||||
}
|
||||
}
|
||||
got := map[uint16][]byte{}
|
||||
rawHandlers[firstID] = func(_ *Session, body []byte) error { got[firstID] = append([]byte(nil), body...); return nil }
|
||||
rawHandlers[secondID] = func(_ *Session, body []byte) error { got[secondID] = append([]byte(nil), body...); return nil }
|
||||
defer delete(rawHandlers, firstID)
|
||||
defer delete(rawHandlers, secondID)
|
||||
const conv, token uint32 = 77, 88
|
||||
segment := func(seq uint32, fragment byte, frame []byte) []byte {
|
||||
out := make([]byte, kcplib.Overhead+len(frame))
|
||||
binary.LittleEndian.PutUint32(out[0:], conv)
|
||||
binary.LittleEndian.PutUint32(out[4:], token)
|
||||
out[8], out[9] = kcplib.CmdPush, fragment
|
||||
binary.LittleEndian.PutUint16(out[10:], 128)
|
||||
binary.LittleEndian.PutUint32(out[16:], seq)
|
||||
binary.LittleEndian.PutUint32(out[24:], uint32(len(frame)))
|
||||
copy(out[kcplib.Overhead:], frame)
|
||||
return out
|
||||
}
|
||||
payload := bytes.Repeat([]byte("first-fragment-"), 120)
|
||||
frame := (&NetPacket{CmdType: firstID, Body: payload}).Encode()
|
||||
following := (&NetPacket{CmdType: secondID, Body: []byte("next")}).Encode()
|
||||
s := NewSession(func([]byte) error { return nil }, conv, token)
|
||||
defer s.Close()
|
||||
if err := s.Consume(segment(1, 0, frame[800:])); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatal("dispatched an incomplete out-of-order message")
|
||||
}
|
||||
combined := append(segment(0, 1, frame[:800]), segment(2, 0, following)...)
|
||||
if err := s.Consume(combined); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got[firstID], payload) || string(got[secondID]) != "next" {
|
||||
t.Fatal("fragmented or following packet body was corrupted")
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,13 @@ package gameserver
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"gac-hkrpg-go/internal/common"
|
||||
kcplib "gac-hkrpg-go/pkg/kcp"
|
||||
goproto "gac-hkrpg-go/protocol/proto"
|
||||
)
|
||||
@@ -36,21 +36,25 @@ func TestGatewayHandshakeAndLogin(t *testing.T) {
|
||||
}
|
||||
t.Logf("handshake ok: conv=%d token=%d", conv, token)
|
||||
|
||||
// 2. configure the SERVER-side session held by the gateway
|
||||
player, _ := common.LoadFreesrData()
|
||||
if player == nil {
|
||||
player = &common.FreesrData{}
|
||||
}
|
||||
// 2. Verify the gateway loaded the player data itself.
|
||||
serverSession := gw.GetSession(conv)
|
||||
if serverSession == nil {
|
||||
t.Fatal("gateway did not register the session")
|
||||
}
|
||||
serverSession.SetJSON(player)
|
||||
if serverSession.JSON() == nil {
|
||||
t.Fatal("gateway session has no player data")
|
||||
}
|
||||
|
||||
// 3. send PlayerGetTokenCsReq (cmd 5)
|
||||
reqID := uint16(goproto.CmdIDByName["PlayerGetTokenCsReq"])
|
||||
rspID := uint16(goproto.CmdIDByName["PlayerGetTokenScRsp"])
|
||||
if err := client.sendCmd(reqID, nil); err != nil {
|
||||
// Synthetic fields exercise non-empty requests without storing account data.
|
||||
tokenRequest := &goproto.PlayerGetTokenCsReq{AccountUid: "52022", Token: strings.Repeat("x", 32), Uid: playerUID, Platform: 3}
|
||||
tokenBody, err := proto.Marshal(tokenRequest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.sendCmd(reqID, tokenBody); err != nil {
|
||||
t.Fatalf("send cmd: %v", err)
|
||||
}
|
||||
|
||||
@@ -71,6 +75,69 @@ func TestGatewayHandshakeAndLogin(t *testing.T) {
|
||||
}
|
||||
t.Logf("login token ok: retcode=%d uid=%d msg=%q", rsp.Retcode, rsp.Uid, rsp.Msg)
|
||||
|
||||
// 4. Login must also round-trip through the same UDP/KCP session.
|
||||
loginReq := &goproto.PlayerLoginCsReq{LoginRandom: 123456, Signature: strings.Repeat("x", 768)}
|
||||
loginBody, err := proto.Marshal(loginReq)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.sendCmd(uint16(goproto.CmdIDByName["PlayerLoginCsReq"]), loginBody); err != nil {
|
||||
t.Fatalf("send player login: %v", err)
|
||||
}
|
||||
cmdID, body, err = client.recvCmd(3 * time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("no player login response: %v", err)
|
||||
}
|
||||
if want := uint16(goproto.CmdIDByName["PlayerLoginScRsp"]); cmdID != want {
|
||||
t.Fatalf("login response command: got %d want %d", cmdID, want)
|
||||
}
|
||||
loginRsp := &goproto.PlayerLoginScRsp{}
|
||||
if err := proto.Unmarshal(body, loginRsp); err != nil {
|
||||
t.Fatalf("decode login response: %v", err)
|
||||
}
|
||||
if loginRsp.Retcode != 0 || loginRsp.LoginRandom != loginReq.LoginRandom || loginRsp.BasicInfo == nil {
|
||||
t.Fatalf("invalid player login response: %v", loginRsp)
|
||||
}
|
||||
|
||||
// The Android client asks for level rewards before login finish.
|
||||
if err := client.sendCmd(uint16(goproto.CmdIDByName["GetLevelRewardTakenListCsReq"]), nil); err != nil {
|
||||
t.Fatalf("send level rewards request: %v", err)
|
||||
}
|
||||
cmdID, body, err = client.recvCmd(3 * time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("no level rewards response: %v", err)
|
||||
}
|
||||
if want := uint16(goproto.CmdIDByName["GetLevelRewardTakenListScRsp"]); cmdID != want {
|
||||
t.Fatalf("level rewards response command: got %d want %d", cmdID, want)
|
||||
}
|
||||
levelRsp := &goproto.GetLevelRewardTakenListScRsp{}
|
||||
if err := proto.Unmarshal(body, levelRsp); err != nil || levelRsp.Retcode != 0 {
|
||||
t.Fatalf("invalid level rewards response: %v / %v", levelRsp, err)
|
||||
}
|
||||
|
||||
if err := client.sendCmd(uint16(goproto.CmdIDByName["PlayerLoginFinishCsReq"]), nil); err != nil {
|
||||
t.Fatalf("send login finish: %v", err)
|
||||
}
|
||||
seenFinish := false
|
||||
for i := 0; i < 3 && !seenFinish; i++ {
|
||||
cmdID, body, err = client.recvCmd(3 * time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("no login finish response: %v", err)
|
||||
}
|
||||
if cmdID == uint16(goproto.CmdIDByName["PlayerLoginFinishScRsp"]) {
|
||||
finishRsp := &goproto.PlayerLoginFinishScRsp{}
|
||||
if err := proto.Unmarshal(body, finishRsp); err != nil || finishRsp.Retcode != 0 {
|
||||
t.Fatalf("invalid login finish response: %v / %v", finishRsp, err)
|
||||
}
|
||||
seenFinish = true
|
||||
} else if cmdID != uint16(goproto.CmdIDByName["ContentPackageSyncDataScNotify"]) {
|
||||
t.Fatalf("unexpected login finish packet: %d", cmdID)
|
||||
}
|
||||
}
|
||||
if !seenFinish {
|
||||
t.Fatal("login finish response missing")
|
||||
}
|
||||
|
||||
// 4. exercise the SceneCastSkill fix: attack with no real monsters ->
|
||||
// must NOT enter battle (no battle_info in the response)
|
||||
skillReqID := uint16(goproto.CmdIDByName["SceneCastSkillCsReq"])
|
||||
|
||||
+31
-30
@@ -835,35 +835,42 @@ func apiCommand(w http.ResponseWriter, r *http.Request) {
|
||||
success = false
|
||||
|
||||
case "/max":
|
||||
for _, av := range data.Avatars {
|
||||
av.Level = 80
|
||||
av.Promotion = 6
|
||||
av.Data.Rank = 6
|
||||
count, err := common.MaxAvatarProgress()
|
||||
if err != nil {
|
||||
output, success = "Cannot update avatars: "+err.Error(), false
|
||||
} else {
|
||||
output = fmt.Sprintf("All %d avatars saved at Level 80, Rank 6. Reconnect the game to load the change.", count)
|
||||
}
|
||||
output = fmt.Sprintf("[OK] All %d avatars set to Level 80, Rank 6.", len(data.Avatars))
|
||||
|
||||
case "/teleport", "/scene":
|
||||
if len(parts) > 1 {
|
||||
planeID, _ := strconv.ParseUint(parts[1], 10, 32)
|
||||
if planeID != 0 {
|
||||
data.Scene.PlaneID = uint32(planeID)
|
||||
output = fmt.Sprintf("[OK] Teleported to PlaneID %d.", planeID)
|
||||
if len(parts) < 2 || len(parts) > 3 {
|
||||
output = "Usage: /teleport <plane_id> [floor_id]"
|
||||
success = false
|
||||
break
|
||||
}
|
||||
if len(parts) > 2 {
|
||||
floorID, _ := strconv.ParseUint(parts[2], 10, 32)
|
||||
planeID, parseErr := strconv.ParseUint(parts[1], 10, 32)
|
||||
if parseErr != nil || planeID == 0 {
|
||||
output, success = "Invalid plane ID.", false
|
||||
break
|
||||
}
|
||||
var floorID uint64
|
||||
if len(parts) == 3 {
|
||||
floorID, parseErr = strconv.ParseUint(parts[2], 10, 32)
|
||||
if parseErr != nil || floorID == 0 {
|
||||
output, success = "Invalid floor ID.", false
|
||||
break
|
||||
}
|
||||
}
|
||||
data.Scene.PlaneID = uint32(planeID)
|
||||
if floorID != 0 {
|
||||
data.Scene.FloorID = uint32(floorID)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output = "Usage: /teleport <plane_id> [floor_id]"
|
||||
success = false
|
||||
}
|
||||
output = fmt.Sprintf("Scene saved for plane %d. Reconnect the game to load the change.", planeID)
|
||||
|
||||
case "/clear":
|
||||
if len(parts) > 1 && parts[1] == "lineup" {
|
||||
data.Lineups = make(map[uint32]uint32)
|
||||
output = "[OK] Active lineup cleared."
|
||||
if len(parts) == 2 && strings.EqualFold(parts[1], "lineup") {
|
||||
output = "The active lineup cannot be empty. Change your team in the game."
|
||||
success = false
|
||||
} else {
|
||||
output = "Usage: /clear lineup"
|
||||
success = false
|
||||
@@ -989,8 +996,8 @@ func apiCommand(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
case "/help":
|
||||
output = "/faketime [status|off|Unix seconds|RFC3339 with timezone] - Set game calendar time\n"
|
||||
output += "Available GM Commands:\n /max - Set all avatars to Max Lv.80 E6\n /teleport <plane_id> [floor_id] - Set scene\n /avatar <id> <level> [rank] - Modify avatar\n /clear lineup - Clear active lineup slots\n /unlockall warp [all|avatar|lightcone|off] - Toggle unlocked gacha banners\n /reload banner - Reload banner configurations"
|
||||
output = "/faketime [status|off|window <begin Unix seconds> <end Unix seconds>|Unix seconds|RFC3339 with timezone] - Set game calendar time\n"
|
||||
output += "Available GM Commands:\n /max - Set all avatars to Max Lv.80 E6\n /teleport <plane_id> [floor_id] - Set scene\n /avatar <id> <level> [rank] - Modify avatar\n /unlockall warp [all|avatar|lightcone|off] - Toggle unlocked gacha banners\n /reload banner - Reload banner configurations"
|
||||
|
||||
default:
|
||||
output = fmt.Sprintf("Unknown command: %s", cmd)
|
||||
@@ -1007,14 +1014,8 @@ func apiCommand(w http.ResponseWriter, r *http.Request) {
|
||||
output = err.Error()
|
||||
}
|
||||
}
|
||||
} else if success && rootCmd != "/help" && rootCmd != "/avatar" {
|
||||
raw, err := json.MarshalIndent(data, "", " ")
|
||||
if err == nil {
|
||||
err = os.WriteFile("freesr-data.json", raw, 0o644)
|
||||
}
|
||||
if err == nil {
|
||||
err = data.SavePersistentChecked()
|
||||
}
|
||||
} else if success && rootCmd != "/help" && rootCmd != "/avatar" && rootCmd != "/max" {
|
||||
err := data.SavePersistentChecked()
|
||||
if err != nil {
|
||||
success = false
|
||||
output = err.Error()
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
func apiCurrencyWarsCommand(w http.ResponseWriter, command string) {
|
||||
fail := func(err error) { writeJSON(w, map[string]any{"success": false, "output": err.Error()}) }
|
||||
parts := strings.Fields(command)
|
||||
usage := "Usage: /cw status | /cw list role|equip|orb|consumable|forge | /cw gold <total> | /cw hp <total> | /cw exp <gain> | /cw tbpower <total> [costed] | /cw equip|orb|consumable|forge <itemID> [count] (for orb: <itemID> or 'random') | /cw role <roleID> <star> [count]. Changes apply in CW Preparation."
|
||||
usage := "Usage: /cw status | /cw end | /cw list role|equip|orb|consumable|forge | /cw gold <total> | /cw hp <total> | /cw exp <gain> | /cw tbpower <total> [costed] | /cw equip|orb|consumable|forge <itemID> [count] (for orb: <itemID> or 'random') | /cw role <roleID> <star> [count]. Changes apply in CW Preparation."
|
||||
if len(parts) < 2 {
|
||||
fail(fmt.Errorf("%s", usage))
|
||||
return
|
||||
@@ -132,6 +132,19 @@ func apiCurrencyWarsCommand(w http.ResponseWriter, command string) {
|
||||
return
|
||||
}
|
||||
field := strings.ToLower(parts[1])
|
||||
if field == common.CurrencyWarsCommandEnd {
|
||||
if len(parts) != 2 {
|
||||
fail(fmt.Errorf("Usage: /cw end"))
|
||||
return
|
||||
}
|
||||
id, err := common.QueueCurrencyWarsCommand(cfg.PlayerUID, run.UniqueId, common.CurrencyWarsCommandEnd, 0)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"success": true, "state": "queued", "request_id": id, "output": fmt.Sprintf("Queued CW end for run %d. The game server will settle it as a quit on heartbeat after any active Battle; this is not a victory. Keep the game connected and check /cw status or CW_GM logs.", run.UniqueId)})
|
||||
return
|
||||
}
|
||||
if field == "tb" || field == "trailblaze" {
|
||||
field = "tbpower"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gac-hkrpg-go/internal/common"
|
||||
pb "gac-hkrpg-go/protocol/proto"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestCurrencyWarsEndCommandQueuesOnlyActiveRun(t *testing.T) {
|
||||
chdirRepoRootSDK(t)
|
||||
t.Setenv("GAC_CONFIG", "server.json")
|
||||
if err := os.WriteFile("persistent", []byte(`{}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := common.LoadCurrencyWarsCatalog()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := common.LoadRuntimeConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := &pb.GridFightGetDataScRsp{DHFOFLFCLAH: &pb.MPIMAEMCEFH{UniqueId: 5, DivisionId: c.CapturedStages[0].ID, LJNGPCKPJLF: c.ActiveSeasonID, NAGDJGMMCBE: []*pb.GridFightGameInfo{{LEOIAOFBBFA: &pb.GridFightGameInfo_FAOCFBOFCJP{FAOCFBOFCJP: &pb.NIHNGPKHEPC{}}}}}}
|
||||
wire, err := proto.Marshal(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot := common.NewCurrencyWarsSnapshot(cfg.PlayerUID, c.ClientVersion, c.Provenance.GameAssemblySHA256, wire)
|
||||
if _, err := common.CommitCurrencyWarsSnapshot(0, snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
invoke := func(command string) map[string]any {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
apiCurrencyWarsCommand(w, command)
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
if result := invoke("/cw end now"); result["success"] != false {
|
||||
t.Fatal("end accepted extra arguments")
|
||||
}
|
||||
queued, err := common.ReadCurrencyWarsCommands()
|
||||
if err != nil || len(queued) != 0 {
|
||||
t.Fatal("invalid request queued a command")
|
||||
}
|
||||
result := invoke("/cw end")
|
||||
if result["success"] != true || result["state"] != "queued" || !strings.Contains(result["output"].(string), "quit") {
|
||||
t.Fatalf("unexpected response: %v", result)
|
||||
}
|
||||
queued, err = common.ReadCurrencyWarsCommands()
|
||||
if err != nil || len(queued) != 1 || queued[0].UID != cfg.PlayerUID || queued[0].RunID != data.DHFOFLFCLAH.UniqueId || queued[0].Field != common.CurrencyWarsCommandEnd {
|
||||
t.Fatalf("unexpected queue: %v %v", queued, err)
|
||||
}
|
||||
current, err := common.ReadCurrencyWarsSnapshot(cfg.PlayerUID, c.ClientVersion, c.Provenance.GameAssemblySHA256)
|
||||
if err != nil || current.Revision != 1 || string(current.WireData) != string(wire) {
|
||||
t.Fatal("API changed active run instead of queuing")
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
func apiFakeTimeCommand(w http.ResponseWriter, cmd string) {
|
||||
parts := strings.Fields(cmd)
|
||||
usage := "Usage: /faketime [status | off | <Unix seconds> | <RFC3339 date with timezone>]. Time keeps advancing. Reopen the guide after the next heartbeat; relogin if it caches schedules."
|
||||
usage := "Usage: /faketime [status | off | window <begin Unix seconds> <end Unix seconds> | <Unix seconds> | <RFC3339 date with timezone>]. Window mode wraps the game clock at the end; off restores real time. Reopen the guide after the next heartbeat; relogin if it caches schedules."
|
||||
var now time.Time
|
||||
var err error
|
||||
arg := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
|
||||
@@ -21,6 +21,20 @@ func apiFakeTimeCommand(w http.ResponseWriter, cmd string) {
|
||||
case "off", "reset", "real":
|
||||
now, err = common.SetGameTime(nil)
|
||||
default:
|
||||
if len(parts) > 1 && strings.EqualFold(parts[1], "window") {
|
||||
if len(parts) != 4 {
|
||||
err = fmt.Errorf("window requires begin and end Unix seconds")
|
||||
} else {
|
||||
begin, e1 := strconv.ParseInt(parts[2], 10, 64)
|
||||
end, e2 := strconv.ParseInt(parts[3], 10, 64)
|
||||
if e1 != nil || e2 != nil || begin < 0 || end > 253402300799 || end <= begin {
|
||||
err = fmt.Errorf("invalid window Unix seconds")
|
||||
} else {
|
||||
now, err = common.SetGameTimeWindow(time.Unix(begin, 0), time.Unix(end, 0))
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
var target time.Time
|
||||
if seconds, e := strconv.ParseInt(arg, 10, 64); e == nil {
|
||||
if seconds < 0 || seconds > 253402300799 {
|
||||
@@ -44,5 +58,5 @@ func apiFakeTimeCommand(w http.ResponseWriter, cmd string) {
|
||||
writeJSON(w, map[string]any{"success": false, "output": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"success": true, "command": cmd, "game_time": now.UTC().Format(time.RFC3339), "unix_seconds": now.Unix(), "offset_ms": state.OffsetMS, "output": fmt.Sprintf("Game time: %s (offset %d ms). %s", now.UTC().Format(time.RFC3339), state.OffsetMS, usage)})
|
||||
writeJSON(w, map[string]any{"success": true, "command": cmd, "game_time": now.UTC().Format(time.RFC3339), "unix_seconds": now.Unix(), "offset_ms": state.OffsetMS, "window_begin_ms": state.WindowBeginMS, "window_end_ms": state.WindowEndMS, "output": fmt.Sprintf("Game time: %s (offset %d ms, window %d..%d ms). %s", now.UTC().Format(time.RFC3339), state.OffsetMS, state.WindowBeginMS, state.WindowEndMS, usage)})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gac-hkrpg-go/internal/common"
|
||||
)
|
||||
|
||||
func TestFakeTimeWindowCommand(t *testing.T) {
|
||||
chdirRepoRootSDK(t)
|
||||
t.Setenv("GAC_CONFIG", "server.json")
|
||||
t.Setenv("GAC_GAME_CLOCK_STATE_FILE", filepath.Join(t.TempDir(), "clock.json"))
|
||||
call := func(command string) map[string]any {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
apiFakeTimeCommand(w, command)
|
||||
var response map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return response
|
||||
}
|
||||
if result := call("/faketime window 1000 2000"); result["success"] != true || result["unix_seconds"] != float64(1500) {
|
||||
t.Fatalf("window command: %v", result)
|
||||
}
|
||||
before, err := common.ReadGameClock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result := call("/faketime window 2000 1000"); result["success"] != false {
|
||||
t.Fatalf("invalid window accepted: %v", result)
|
||||
}
|
||||
after, err := common.ReadGameClock()
|
||||
if err != nil || before != after {
|
||||
t.Fatalf("invalid window changed clock: %+v %v", after, err)
|
||||
}
|
||||
if result := call("/faketime off"); result["success"] != true {
|
||||
t.Fatalf("reset: %v", result)
|
||||
}
|
||||
if state, err := common.ReadGameClock(); err != nil || state.WindowEndMS != 0 {
|
||||
t.Fatalf("reset did not remove window: %+v %v", state, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMaxCommandPreservesUnknownData(t *testing.T) {
|
||||
chdirRepoRootSDK(t)
|
||||
raw, err := os.ReadFile("freesr-data.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var root map[string]json.RawMessage
|
||||
if err = json.Unmarshal(raw, &root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root["future_root"] = json.RawMessage(`{"keep":true}`)
|
||||
var avatars map[string]json.RawMessage
|
||||
if err = json.Unmarshal(root["avatars"], &avatars); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var avatar map[string]json.RawMessage
|
||||
if err = json.Unmarshal(avatars["1204"], &avatar); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
avatar["future_avatar"] = json.RawMessage(`"preserved"`)
|
||||
avatars["1204"], _ = json.Marshal(avatar)
|
||||
root["avatars"], _ = json.Marshal(avatars)
|
||||
raw, _ = json.Marshal(root)
|
||||
if err = os.WriteFile("freesr-data.json", raw, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
beforePersistent, err := os.ReadFile("persistent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
apiCommand(w, httptest.NewRequest(http.MethodPost, "/api/command", strings.NewReader(`{"command":"/max"}`)))
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
if err = json.Unmarshal(w.Body.Bytes(), &result); err != nil || !result.Success {
|
||||
t.Fatalf("command failed: %s (%v)", w.Body.String(), err)
|
||||
}
|
||||
raw, _ = os.ReadFile("freesr-data.json")
|
||||
if err = json.Unmarshal(raw, &root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var futureRoot struct {
|
||||
Keep bool `json:"keep"`
|
||||
}
|
||||
if err = json.Unmarshal(root["future_root"], &futureRoot); err != nil || !futureRoot.Keep {
|
||||
t.Fatal("root data lost")
|
||||
}
|
||||
if err = json.Unmarshal(root["avatars"], &avatars); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = json.Unmarshal(avatars["1204"], &avatar); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var data struct {
|
||||
Rank int `json:"rank"`
|
||||
}
|
||||
if err = json.Unmarshal(avatar["data"], &data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(avatar["future_avatar"]) != `"preserved"` || string(avatar["level"]) != "80" || string(avatar["promotion"]) != "6" || data.Rank != 6 {
|
||||
t.Fatal("avatar fields lost or not updated")
|
||||
}
|
||||
afterPersistent, err := os.ReadFile("persistent")
|
||||
if err != nil || string(beforePersistent) != string(afterPersistent) {
|
||||
t.Fatal("persistent data was rewritten")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSceneCommandsPreserveImportedSave(t *testing.T) {
|
||||
chdirRepoRootSDK(t)
|
||||
before, err := os.ReadFile("freesr-data.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := func(command string) bool {
|
||||
body, _ := json.Marshal(map[string]string{"command": command})
|
||||
w := httptest.NewRecorder()
|
||||
apiCommand(w, httptest.NewRequest(http.MethodPost, "/api/command", strings.NewReader(string(body))))
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result.Success
|
||||
}
|
||||
persistentBefore, err := os.ReadFile("persistent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, invalid := range []string{"/teleport invalid", "/scene 0", "/teleport 123 bad", "/teleport 123 456 extra", "/clear lineup"} {
|
||||
if run(invalid) {
|
||||
t.Fatalf("accepted %s", invalid)
|
||||
}
|
||||
after, _ := os.ReadFile("persistent")
|
||||
if string(after) != string(persistentBefore) {
|
||||
t.Fatalf("invalid command %s wrote persistent", invalid)
|
||||
}
|
||||
}
|
||||
for _, command := range []string{"/teleport 123 456"} {
|
||||
if !run(command) {
|
||||
t.Fatalf("command failed: %s", command)
|
||||
}
|
||||
after, err := os.ReadFile("freesr-data.json")
|
||||
if err != nil || string(after) != string(before) {
|
||||
t.Fatalf("%s changed imported save", command)
|
||||
}
|
||||
}
|
||||
raw, err := os.ReadFile("persistent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var saved struct {
|
||||
Scene struct {
|
||||
PlaneID uint32 `json:"plane_id"`
|
||||
FloorID uint32 `json:"floor_id"`
|
||||
} `json:"scene"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Scene.PlaneID != 123 || saved.Scene.FloorID != 456 {
|
||||
t.Fatalf("persistent changes missing: %+v", saved)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBeforeVerifyReturnsDataObject(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
beforeVerifyReply(w)
|
||||
var body struct {
|
||||
Retcode int `json:"retcode"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != 200 || body.Retcode != 0 || body.Data == nil {
|
||||
t.Fatalf("beforeVerify response cannot initialize SDK data: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadHotfixIgnoresEmptyCacheWhenBundledEntryExists(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
if err := os.WriteFile("hotfix.json", []byte(`{"bundled":{"asset_bundle_url":"bundle"},"override":{"asset_bundle_url":"old"}}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile("versions.json", []byte(`{"bundled":{},"override":{"asset_bundle_url":"user"},"cached":{"asset_bundle_url":"cached"}}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := loadHotfix()
|
||||
if got["bundled"].AssetBundleURL != "bundle" || got["override"].AssetBundleURL != "user" || got["cached"].AssetBundleURL != "cached" {
|
||||
t.Fatalf("incorrect hotfix cache merge: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -42,8 +42,21 @@ func loadHotfix() map[string]VersionConfig {
|
||||
if raw, err := os.ReadFile("hotfix.json"); err == nil {
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
}
|
||||
if out == nil {
|
||||
out = map[string]VersionConfig{}
|
||||
}
|
||||
if raw, err := os.ReadFile("versions.json"); err == nil {
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
var cached map[string]VersionConfig
|
||||
if json.Unmarshal(raw, &cached) == nil {
|
||||
for version, cfg := range cached {
|
||||
// A failed fetch previously persisted an empty cache entry. It
|
||||
// must not hide usable bundled metadata for the same version.
|
||||
if cfg == (VersionConfig{}) && out[version] != (VersionConfig{}) {
|
||||
continue
|
||||
}
|
||||
out[version] = cfg
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -201,6 +214,7 @@ func queryGateway(state *appState) http.HandlerFunc {
|
||||
_ = params{}
|
||||
|
||||
cfg := state.getOrInsertHotfix(version, seed)
|
||||
Log(fmt.Sprintf("[gateway] version=%q game=%s:%d asset=%t ex_resource=%t lua=%t", version, state.runtime.PublicGameHost, state.runtime.GamePort, cfg.AssetBundleURL != "", cfg.ExResourceURL != "", cfg.LuaURL != ""))
|
||||
if state.runtime.LuaOverlay.Enabled {
|
||||
cfg.LuaURL = state.runtime.PublicSDKURL + luaOverlayRoute
|
||||
}
|
||||
@@ -305,6 +319,12 @@ func (a *appState) granterLoginReply(w http.ResponseWriter) {
|
||||
})
|
||||
}
|
||||
|
||||
// The Android SDK decodes data as BeforeVerifyEntity before continuing
|
||||
// into the game. An empty object supplies its default false flags.
|
||||
func beforeVerifyReply(w http.ResponseWriter) {
|
||||
writeJSON(w, map[string]any{"data": map[string]any{}, "message": "OK", "retcode": 0})
|
||||
}
|
||||
|
||||
func riskyCheckReply(w http.ResponseWriter) {
|
||||
writeJSON(w, map[string]any{
|
||||
"data": map[string]any{
|
||||
@@ -628,6 +648,7 @@ func Listen() error {
|
||||
mux.HandleFunc("/{product_name}/combo/granter/api/getConfig", func(w http.ResponseWriter, _ *http.Request) { comboGetConfigReply(w) })
|
||||
mux.HandleFunc("/{product_name}/combo/granter/api/compareProtocolVersion", func(w http.ResponseWriter, _ *http.Request) { compareProtocolVersionReply(w) })
|
||||
mux.HandleFunc("/{product_name}/combo/granter/login/v2/login", func(w http.ResponseWriter, _ *http.Request) { state.granterLoginReply(w) })
|
||||
mux.HandleFunc("/{product_name}/combo/granter/login/beforeVerify", func(w http.ResponseWriter, _ *http.Request) { beforeVerifyReply(w) })
|
||||
mux.HandleFunc("/{product_name}/mdk/agreement/api/getAgreementInfos", func(w http.ResponseWriter, _ *http.Request) { agreementInfosReply(w) })
|
||||
|
||||
mux.HandleFunc("/combo/box/api/config/sdk/combo", func(w http.ResponseWriter, _ *http.Request) { comboConfigReply(w) })
|
||||
@@ -664,6 +685,8 @@ func Listen() error {
|
||||
state.loginReply(w, r)
|
||||
case strings.Contains(path, "combo/granter/login/v2/login"):
|
||||
state.granterLoginReply(w)
|
||||
case strings.Contains(path, "combo/granter/login/beforeVerify"):
|
||||
beforeVerifyReply(w)
|
||||
case strings.Contains(path, "mdk/shield/api/loadConfig"):
|
||||
shieldLoadConfigReply(w)
|
||||
case strings.Contains(path, "combo/granter/api/getConfig"):
|
||||
|
||||
@@ -213,6 +213,10 @@ func (k *KCP) State() int32 { return k.state }
|
||||
// IsDeadLink reports whether resends exceeded the dead link limit.
|
||||
func (k *KCP) IsDeadLink() bool { return k.state != 0 }
|
||||
|
||||
// PendingSends reports segments waiting to be transmitted or acknowledged.
|
||||
// Callers must serialize access with other KCP operations.
|
||||
func (k *KCP) PendingSends() int { return len(k.sndQueue) + len(k.sndBuf) }
|
||||
|
||||
func (k *KCP) moveBuf() {
|
||||
for len(k.rcvBuf) > 0 {
|
||||
seg := &k.rcvBuf[0]
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"asset_verification_note": "2026-10-24 Player.log: all six captured banner prefab paths were checked via RPG.Client.GlobalVars.s_AssetMgr.Exists without loading. Standard and Novice exist; all four captured limited character/lightcone prefabs (UI/Drawcard/GachaPanelLimited/*) are absent from the installed asset manifest. The captured Seele prefab UI/Drawcard/GachaPanelLimited/AvatarGacha_1102.prefab previously caused UIPrefabLoader ArgumentNullException inside GachaMainPage._create_pools_panel when sent. Do not enable a prefab solely because it exists in the flat table.",
|
||||
"mapped_banner_preview_count": 0,
|
||||
"native_presentation": {
|
||||
"enabled": false,
|
||||
"enabled": true,
|
||||
"template_gacha_id": 1001,
|
||||
"source": "Current initialized Standard-pool native cloning and GetInfo pre-bind restoration; user confirmed one-banner native frame, splash art and horizontal thumbnail on 2026-10-25. Five-star mapped tabs only, with three matching sandbox four-star cards; full multi-tab draw/exit still awaiting Client confirmation."
|
||||
},
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
"ExcelOutput/RelicMainAffixConfig.json",
|
||||
"ExcelOutput/RelicSubAffixConfig.json"
|
||||
],
|
||||
"note": "Material, Usable and Mission item IDs; all released Light Cones; and visible BASIC Relic templates joined from RelicConfig and ItemConfigRelic. MainAffixID is selected from the matching MainAffixGroup; sub-affixes come from the matching SubAffixGroup and exclude the main property. Display, Virtual and Pet records are excluded."
|
||||
"note": "Material, Usable and Mission item IDs; all released Light Cones; and visible BASIC Relic templates joined from RelicConfig and ItemConfigRelic. MainAffixID is selected from the matching MainAffixGroup; sub-affixes come from the matching SubAffixGroup and exclude the main property. Display, Virtual and Pet records are excluded.",
|
||||
"supplementary_relic_capture": "resources/RelicClientCapture.json",
|
||||
"supplementary_note": "Four BASIC RelicConfig rows and affix groups were read from Client; ItemConfigRelic visibility was not captured."
|
||||
},
|
||||
"item_ids": [
|
||||
101,
|
||||
@@ -24045,6 +24047,114 @@
|
||||
"step": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"relic_id": 63295,
|
||||
"relic_set_id": 329,
|
||||
"main_affix_id": 1,
|
||||
"sub_affixes": [
|
||||
{
|
||||
"sub_affix_id": 1,
|
||||
"count": 1,
|
||||
"step": 0
|
||||
},
|
||||
{
|
||||
"sub_affix_id": 2,
|
||||
"count": 1,
|
||||
"step": 0
|
||||
},
|
||||
{
|
||||
"sub_affix_id": 3,
|
||||
"count": 1,
|
||||
"step": 0
|
||||
},
|
||||
{
|
||||
"sub_affix_id": 5,
|
||||
"count": 1,
|
||||
"step": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"relic_id": 63296,
|
||||
"relic_set_id": 329,
|
||||
"main_affix_id": 1,
|
||||
"sub_affixes": [
|
||||
{
|
||||
"sub_affix_id": 1,
|
||||
"count": 1,
|
||||
"step": 0
|
||||
},
|
||||
{
|
||||
"sub_affix_id": 2,
|
||||
"count": 1,
|
||||
"step": 0
|
||||
},
|
||||
{
|
||||
"sub_affix_id": 3,
|
||||
"count": 1,
|
||||
"step": 0
|
||||
},
|
||||
{
|
||||
"sub_affix_id": 4,
|
||||
"count": 1,
|
||||
"step": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"relic_id": 63305,
|
||||
"relic_set_id": 330,
|
||||
"main_affix_id": 1,
|
||||
"sub_affixes": [
|
||||
{
|
||||
"sub_affix_id": 1,
|
||||
"count": 1,
|
||||
"step": 0
|
||||
},
|
||||
{
|
||||
"sub_affix_id": 2,
|
||||
"count": 1,
|
||||
"step": 0
|
||||
},
|
||||
{
|
||||
"sub_affix_id": 3,
|
||||
"count": 1,
|
||||
"step": 0
|
||||
},
|
||||
{
|
||||
"sub_affix_id": 5,
|
||||
"count": 1,
|
||||
"step": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"relic_id": 63306,
|
||||
"relic_set_id": 330,
|
||||
"main_affix_id": 1,
|
||||
"sub_affixes": [
|
||||
{
|
||||
"sub_affix_id": 1,
|
||||
"count": 1,
|
||||
"step": 0
|
||||
},
|
||||
{
|
||||
"sub_affix_id": 2,
|
||||
"count": 1,
|
||||
"step": 0
|
||||
},
|
||||
{
|
||||
"sub_affix_id": 3,
|
||||
"count": 1,
|
||||
"step": 0
|
||||
},
|
||||
{
|
||||
"sub_affix_id": 4,
|
||||
"count": 1,
|
||||
"step": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
{
|
||||
"source": {
|
||||
"kind": "client table capture",
|
||||
"client_version": "4.6.54",
|
||||
"game_assembly_sha256": "4aa08a0706f27d2cf9951b61920c179140ece9ea94f7e34422c18e46922939b6",
|
||||
"captured_at": "2026-11-02",
|
||||
"tables": [
|
||||
"RelicConfigExcelTable",
|
||||
"RelicMainAffixConfigExcelTable",
|
||||
"RelicSubAffixConfigExcelTable"
|
||||
],
|
||||
"scope": "Four relic IDs and main affix groups 55/56 and sub-affix group 5."
|
||||
},
|
||||
"relics": [
|
||||
{
|
||||
"Rarity": 5,
|
||||
"Mode": 1,
|
||||
"ID": 63295,
|
||||
"MainAffixGroup": 55,
|
||||
"MaxLevel": 15,
|
||||
"SubAffixGroup": 5,
|
||||
"SetID": 329,
|
||||
"ExpProvide": 1500,
|
||||
"ExpType": 4,
|
||||
"Type": 5,
|
||||
"CoinCost": 2250
|
||||
},
|
||||
{
|
||||
"Rarity": 5,
|
||||
"Mode": 1,
|
||||
"ID": 63296,
|
||||
"MainAffixGroup": 56,
|
||||
"MaxLevel": 15,
|
||||
"SubAffixGroup": 5,
|
||||
"SetID": 329,
|
||||
"ExpProvide": 1500,
|
||||
"ExpType": 4,
|
||||
"Type": 6,
|
||||
"CoinCost": 2250
|
||||
},
|
||||
{
|
||||
"Rarity": 5,
|
||||
"Mode": 1,
|
||||
"ID": 63305,
|
||||
"MainAffixGroup": 55,
|
||||
"MaxLevel": 15,
|
||||
"SubAffixGroup": 5,
|
||||
"SetID": 330,
|
||||
"ExpProvide": 1500,
|
||||
"ExpType": 4,
|
||||
"Type": 5,
|
||||
"CoinCost": 2250
|
||||
},
|
||||
{
|
||||
"Rarity": 5,
|
||||
"Mode": 1,
|
||||
"ID": 63306,
|
||||
"MainAffixGroup": 56,
|
||||
"MaxLevel": 15,
|
||||
"SubAffixGroup": 5,
|
||||
"SetID": 330,
|
||||
"ExpProvide": 1500,
|
||||
"ExpType": 4,
|
||||
"Type": 6,
|
||||
"CoinCost": 2250
|
||||
}
|
||||
],
|
||||
"main_affixes": [
|
||||
{
|
||||
"GroupID": 55,
|
||||
"AffixID": 1,
|
||||
"Property": 32,
|
||||
"LevelAdd": "0.0241920005",
|
||||
"BaseValue": "0.0691199996"
|
||||
},
|
||||
{
|
||||
"GroupID": 55,
|
||||
"AffixID": 2,
|
||||
"Property": 33,
|
||||
"LevelAdd": "0.0241920005",
|
||||
"BaseValue": "0.0691199996"
|
||||
},
|
||||
{
|
||||
"GroupID": 55,
|
||||
"AffixID": 3,
|
||||
"Property": 34,
|
||||
"LevelAdd": "0.0302400004",
|
||||
"BaseValue": "0.0863999999"
|
||||
},
|
||||
{
|
||||
"GroupID": 55,
|
||||
"AffixID": 4,
|
||||
"Property": 12,
|
||||
"LevelAdd": "0.0217730009",
|
||||
"BaseValue": "0.0622080001"
|
||||
},
|
||||
{
|
||||
"GroupID": 55,
|
||||
"AffixID": 5,
|
||||
"Property": 14,
|
||||
"LevelAdd": "0.0217730009",
|
||||
"BaseValue": "0.0622080001"
|
||||
},
|
||||
{
|
||||
"GroupID": 55,
|
||||
"AffixID": 6,
|
||||
"Property": 16,
|
||||
"LevelAdd": "0.0217730009",
|
||||
"BaseValue": "0.0622080001"
|
||||
},
|
||||
{
|
||||
"GroupID": 55,
|
||||
"AffixID": 7,
|
||||
"Property": 18,
|
||||
"LevelAdd": "0.0217730009",
|
||||
"BaseValue": "0.0622080001"
|
||||
},
|
||||
{
|
||||
"GroupID": 55,
|
||||
"AffixID": 8,
|
||||
"Property": 20,
|
||||
"LevelAdd": "0.0217730009",
|
||||
"BaseValue": "0.0622080001"
|
||||
},
|
||||
{
|
||||
"GroupID": 55,
|
||||
"AffixID": 9,
|
||||
"Property": 22,
|
||||
"LevelAdd": "0.0217730009",
|
||||
"BaseValue": "0.0622080001"
|
||||
},
|
||||
{
|
||||
"GroupID": 55,
|
||||
"AffixID": 10,
|
||||
"Property": 24,
|
||||
"LevelAdd": "0.0217730009",
|
||||
"BaseValue": "0.0622080001"
|
||||
},
|
||||
{
|
||||
"GroupID": 56,
|
||||
"AffixID": 1,
|
||||
"Property": 59,
|
||||
"LevelAdd": "0.0362880004",
|
||||
"BaseValue": "0.1036800008"
|
||||
},
|
||||
{
|
||||
"GroupID": 56,
|
||||
"AffixID": 2,
|
||||
"Property": 54,
|
||||
"LevelAdd": "0.0108860011",
|
||||
"BaseValue": "0.0311040000"
|
||||
},
|
||||
{
|
||||
"GroupID": 56,
|
||||
"AffixID": 3,
|
||||
"Property": 32,
|
||||
"LevelAdd": "0.0241920005",
|
||||
"BaseValue": "0.0691199996"
|
||||
},
|
||||
{
|
||||
"GroupID": 56,
|
||||
"AffixID": 4,
|
||||
"Property": 33,
|
||||
"LevelAdd": "0.0241920005",
|
||||
"BaseValue": "0.0691199996"
|
||||
},
|
||||
{
|
||||
"GroupID": 56,
|
||||
"AffixID": 5,
|
||||
"Property": 34,
|
||||
"LevelAdd": "0.0302400004",
|
||||
"BaseValue": "0.0863999999"
|
||||
}
|
||||
],
|
||||
"sub_affixes": [
|
||||
{
|
||||
"StepNum": 2,
|
||||
"GroupID": 5,
|
||||
"BaseValue": "33.870039001",
|
||||
"StepValue": "4.2337550008",
|
||||
"Property": 27,
|
||||
"AffixID": 1
|
||||
},
|
||||
{
|
||||
"StepNum": 2,
|
||||
"GroupID": 5,
|
||||
"BaseValue": "16.935019000",
|
||||
"StepValue": "2.1168770007",
|
||||
"Property": 29,
|
||||
"AffixID": 2
|
||||
},
|
||||
{
|
||||
"StepNum": 2,
|
||||
"GroupID": 5,
|
||||
"BaseValue": "16.935019000",
|
||||
"StepValue": "2.1168770007",
|
||||
"Property": 31,
|
||||
"AffixID": 3
|
||||
},
|
||||
{
|
||||
"StepNum": 2,
|
||||
"GroupID": 5,
|
||||
"BaseValue": "0.0345600005",
|
||||
"StepValue": "0.0043200000",
|
||||
"Property": 32,
|
||||
"AffixID": 4
|
||||
},
|
||||
{
|
||||
"StepNum": 2,
|
||||
"GroupID": 5,
|
||||
"BaseValue": "0.0345600005",
|
||||
"StepValue": "0.0043200000",
|
||||
"Property": 33,
|
||||
"AffixID": 5
|
||||
},
|
||||
{
|
||||
"StepNum": 2,
|
||||
"GroupID": 5,
|
||||
"BaseValue": "0.0431999999",
|
||||
"StepValue": "0.0053999999",
|
||||
"Property": 34,
|
||||
"AffixID": 6
|
||||
},
|
||||
{
|
||||
"StepNum": 2,
|
||||
"GroupID": 5,
|
||||
"BaseValue": "2",
|
||||
"StepValue": "0.3000000002",
|
||||
"Property": 51,
|
||||
"AffixID": 7
|
||||
},
|
||||
{
|
||||
"StepNum": 2,
|
||||
"GroupID": 5,
|
||||
"BaseValue": "0.0259200003",
|
||||
"StepValue": "0.0032400002",
|
||||
"Property": 52,
|
||||
"AffixID": 8
|
||||
},
|
||||
{
|
||||
"StepNum": 2,
|
||||
"GroupID": 5,
|
||||
"BaseValue": "0.0518400007",
|
||||
"StepValue": "0.0064800004",
|
||||
"Property": 53,
|
||||
"AffixID": 9
|
||||
},
|
||||
{
|
||||
"StepNum": 2,
|
||||
"GroupID": 5,
|
||||
"BaseValue": "0.0345600005",
|
||||
"StepValue": "0.0043200000",
|
||||
"Property": 56,
|
||||
"AffixID": 10
|
||||
},
|
||||
{
|
||||
"StepNum": 2,
|
||||
"GroupID": 5,
|
||||
"BaseValue": "0.0345600005",
|
||||
"StepValue": "0.0043200000",
|
||||
"Property": 57,
|
||||
"AffixID": 11
|
||||
},
|
||||
{
|
||||
"StepNum": 2,
|
||||
"GroupID": 5,
|
||||
"BaseValue": "0.0518400007",
|
||||
"StepValue": "0.0064800004",
|
||||
"Property": 59,
|
||||
"AffixID": 12
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Read only affix groups explicitly referenced by the four captured relics.
|
||||
do
|
||||
local groups={{kind='main',group=55,max=10},{kind='main',group=56,max=5},{kind='sub',group=5,max=12}}
|
||||
local s=_G.__gacRelicAffix20261102 or {group=1,id=1}; _G.__gacRelicAffix20261102=s
|
||||
if s.group>#groups then return end
|
||||
local flags=CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags),60)
|
||||
local manager=CS.RPG.Client.GlobalVars.s_GamePhaseManager
|
||||
local phase=manager and typeof(CS.RPG.Client.GamePhaseManager):GetField('_CurrentPhase',flags):GetValue(manager)
|
||||
if not phase or typeof(CS.RPG.Client.BattleGamePhase):IsInstanceOfType(phase) then return end
|
||||
for i=1,3 do
|
||||
local g=groups[s.group]; if not g then return end
|
||||
local id=s.id
|
||||
local ok,err=pcall(function()
|
||||
local tableObj=g.kind=='main' and CS.RPG.GameCore.RelicMainAffixConfigExcelTable or CS.RPG.GameCore.RelicSubAffixConfigExcelTable
|
||||
local row=tableObj.GetData(g.group,id)
|
||||
CS.UnityEngine.Debug.Log('[GAC_RELIC_AFFIX] '..g.kind..' '..g.group..' '..id..' '..CS.Newtonsoft.Json.JsonConvert.SerializeObject(row))
|
||||
end)
|
||||
if not ok then CS.UnityEngine.Debug.Log('[GAC_RELIC_AFFIX] ERROR '..g.kind..' '..g.group..' '..id..' '..tostring(err)) end
|
||||
s.id=s.id+1; if s.id>g.max then s.id=1;s.group=s.group+1 end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Capture only four ItemConfigRelic rows, one per heartbeat, outside battle.
|
||||
do
|
||||
local ids={63295,63296,63305,63306}; local s=_G.__gacRelicItem20261102 or {index=1}; _G.__gacRelicItem20261102=s
|
||||
if s.index>#ids then return end
|
||||
local flags=CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags),60)
|
||||
local manager=CS.RPG.Client.GlobalVars.s_GamePhaseManager
|
||||
local phase=manager and typeof(CS.RPG.Client.GamePhaseManager):GetField('_CurrentPhase',flags):GetValue(manager)
|
||||
if not phase or typeof(CS.RPG.Client.BattleGamePhase):IsInstanceOfType(phase) then return end
|
||||
local id=ids[s.index]
|
||||
local ok,err=pcall(function()
|
||||
local row=CS.RPG.GameCore.ItemConfigRelicExcelTable.GetData(id)
|
||||
CS.UnityEngine.Debug.Log('[GAC_RELIC_ITEM] '..id..' '..CS.Newtonsoft.Json.JsonConvert.SerializeObject(row))
|
||||
end)
|
||||
if not ok then CS.UnityEngine.Debug.Log('[GAC_RELIC_ITEM] ERROR '..id..' '..tostring(err)) end
|
||||
s.index=s.index+1
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Targeted capture of four identified 4.6 planar pieces, one row per heartbeat.
|
||||
do
|
||||
local ids={63295,63296,63305,63306}
|
||||
local s=_G.__gacRelicRows20261102 or {index=1}; _G.__gacRelicRows20261102=s
|
||||
if s.index>#ids then return end
|
||||
local flags=CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags),60)
|
||||
local manager=CS.RPG.Client.GlobalVars.s_GamePhaseManager
|
||||
local phase=manager and typeof(CS.RPG.Client.GamePhaseManager):GetField('_CurrentPhase',flags):GetValue(manager)
|
||||
if not phase or typeof(CS.RPG.Client.BattleGamePhase):IsInstanceOfType(phase) then return end
|
||||
local id=ids[s.index]
|
||||
local ok,err=pcall(function()
|
||||
local row=CS.RPG.GameCore.RelicConfigExcelTable.GetData(id)
|
||||
CS.UnityEngine.Debug.Log('[GAC_RELIC_ROW] '..id..' '..CS.Newtonsoft.Json.JsonConvert.SerializeObject(row))
|
||||
end)
|
||||
if not ok then CS.UnityEngine.Debug.Log('[GAC_RELIC_ROW] ERROR '..id..' '..tostring(err)) end
|
||||
s.index=s.index+1
|
||||
end
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Temporary targeted metadata probe. One named type per heartbeat, outside battle.
|
||||
do
|
||||
local names={'RelicConfigExcelTable','RelicMainAffixConfigExcelTable','RelicSubAffixConfigExcelTable','ItemConfigRelicExcelTable','EquipmentConfigExcelTable'}
|
||||
local s=_G.__gacRelicSchema20261102 or {index=1}; _G.__gacRelicSchema20261102=s
|
||||
if s.index>#names then return end
|
||||
local flags=CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags),60)
|
||||
local manager=CS.RPG.Client.GlobalVars.s_GamePhaseManager
|
||||
local phase=manager and typeof(CS.RPG.Client.GamePhaseManager):GetField('_CurrentPhase',flags):GetValue(manager)
|
||||
if not phase or typeof(CS.RPG.Client.BattleGamePhase):IsInstanceOfType(phase) then return end
|
||||
local name=names[s.index]
|
||||
local ok,err=pcall(function()
|
||||
local a=typeof(CS.RPG.GameCore.AvatarExcelTable).Assembly
|
||||
local t=a:GetType('RPG.GameCore.'..name)
|
||||
CS.UnityEngine.Debug.Log('[GAC_RELIC_SCHEMA] TYPE '..name..' '..tostring(t))
|
||||
if t then
|
||||
local methods=t:GetMethods(flags)
|
||||
for i=0,math.min(methods.Length-1,39) do if methods[i].DeclaringType==t then CS.UnityEngine.Debug.Log('[GAC_RELIC_SCHEMA] METHOD '..tostring(methods[i])) end end
|
||||
end
|
||||
end)
|
||||
if not ok then CS.UnityEngine.Debug.Log('[GAC_RELIC_SCHEMA] ERROR '..name..' '..tostring(err)) end
|
||||
s.index=s.index+1
|
||||
end
|
||||
Reference in new issue
Block a user