package channelserver import ( "bytes" "encoding/binary" "encoding/json" "os" "testing" ) // ── test helpers ───────────────────────────────────────────────────────────── // buildTestSubheaderChunk constructs a minimal sub-header format chunk. // metadata is zero-filled to metaSize bytes. func buildTestSubheaderChunk(t *testing.T, strings []string, metaSize int) []byte { t.Helper() var strBuf bytes.Buffer for _, s := range strings { sjis, err := scenarioEncodeShiftJIS(s) if err != nil { t.Fatalf("encode %q: %v", s, err) } strBuf.Write(sjis) } strBuf.WriteByte(0xFF) // end sentinel totalSize := 8 + metaSize + strBuf.Len() meta := make([]byte, metaSize) // zero metadata var buf bytes.Buffer buf.WriteByte(0x01) // type buf.WriteByte(0x00) // pad buf.WriteByte(byte(totalSize)) // size lo buf.WriteByte(byte(totalSize >> 8)) // size hi buf.WriteByte(byte(len(strings))) // entry count buf.WriteByte(0x00) // unknown1 buf.WriteByte(byte(metaSize)) // metadata total buf.WriteByte(0x00) // unknown2 buf.Write(meta) buf.Write(strBuf.Bytes()) return buf.Bytes() } // buildTestInlineChunk constructs an inline-format chunk0. func buildTestInlineChunk(t *testing.T, strings []string) []byte { t.Helper() var buf bytes.Buffer for i, s := range strings { buf.WriteByte(byte(i + 1)) // 1-based index sjis, err := scenarioEncodeShiftJIS(s) if err != nil { t.Fatalf("encode %q: %v", s, err) } buf.Write(sjis) } return buf.Bytes() } // buildTestScenarioBinary assembles a complete scenario container for testing. func buildTestScenarioBinary(t *testing.T, c0, c1 []byte) []byte { t.Helper() var buf bytes.Buffer if err := binary.Write(&buf, binary.BigEndian, uint32(len(c0))); err != nil { t.Fatal(err) } if err := binary.Write(&buf, binary.BigEndian, uint32(len(c1))); err != nil { t.Fatal(err) } buf.Write(c0) buf.Write(c1) // c2 size = 0 if err := binary.Write(&buf, binary.BigEndian, uint32(0)); err != nil { t.Fatal(err) } return buf.Bytes() } // extractStringsFromScenario parses a binary and returns all strings it contains. func extractStringsFromScenario(t *testing.T, data []byte) []string { t.Helper() s, err := ParseScenarioBinary(data) if err != nil { t.Fatalf("ParseScenarioBinary: %v", err) } var result []string if s.Chunk0 != nil { if s.Chunk0.Subheader != nil { for _, ls := range s.Chunk0.Subheader.Strings { result = append(result, ls.Resolve("")) } } for _, e := range s.Chunk0.Inline { result = append(result, e.Text.Resolve("")) } } if s.Chunk1 != nil && s.Chunk1.Subheader != nil { for _, ls := range s.Chunk1.Subheader.Strings { result = append(result, ls.Resolve("")) } } return result } // ── parse tests ────────────────────────────────────────────────────────────── func TestParseScenarioBinary_TooShort(t *testing.T) { _, err := ParseScenarioBinary([]byte{0x00, 0x01}) if err == nil { t.Error("expected error for short input") } } func TestParseScenarioBinary_EmptyChunks(t *testing.T) { data := buildTestScenarioBinary(t, nil, nil) s, err := ParseScenarioBinary(data) if err != nil { t.Fatalf("unexpected error: %v", err) } if s.Chunk0 != nil || s.Chunk1 != nil || s.Chunk2 != nil { t.Error("expected all chunks nil for empty scenario") } } func TestParseScenarioBinary_SubheaderChunk0(t *testing.T) { c0 := buildTestSubheaderChunk(t, []string{"Quest A", "Quest B"}, 4) data := buildTestScenarioBinary(t, c0, nil) s, err := ParseScenarioBinary(data) if err != nil { t.Fatalf("unexpected error: %v", err) } if s.Chunk0 == nil || s.Chunk0.Subheader == nil { t.Fatal("expected chunk0 subheader") } got := s.Chunk0.Subheader.Strings want := []string{"Quest A", "Quest B"} if len(got) != len(want) { t.Fatalf("string count: got %d, want %d", len(got), len(want)) } for i := range want { if got[i].Resolve("") != want[i] { t.Errorf("[%d]: got %q, want %q", i, got[i].Resolve(""), want[i]) } } } func TestParseScenarioBinary_InlineChunk0(t *testing.T) { c0 := buildTestInlineChunk(t, []string{"Item1", "Item2"}) data := buildTestScenarioBinary(t, c0, nil) s, err := ParseScenarioBinary(data) if err != nil { t.Fatalf("unexpected error: %v", err) } if s.Chunk0 == nil || len(s.Chunk0.Inline) == 0 { t.Fatal("expected chunk0 inline entries") } want := []string{"Item1", "Item2"} for i, e := range s.Chunk0.Inline { if got := e.Text.Resolve(""); got != want[i] { t.Errorf("[%d]: got %q, want %q", i, got, want[i]) } } } func TestParseScenarioBinary_BothChunks(t *testing.T) { c0 := buildTestSubheaderChunk(t, []string{"Quest"}, 4) c1 := buildTestSubheaderChunk(t, []string{"NPC1", "NPC2"}, 8) data := buildTestScenarioBinary(t, c0, c1) strings := extractStringsFromScenario(t, data) want := []string{"Quest", "NPC1", "NPC2"} if len(strings) != len(want) { t.Fatalf("string count: got %d, want %d", len(strings), len(want)) } for i := range want { if strings[i] != want[i] { t.Errorf("[%d]: got %q, want %q", i, strings[i], want[i]) } } } func TestParseScenarioBinary_Japanese(t *testing.T) { c0 := buildTestSubheaderChunk(t, []string{"テスト", "日本語"}, 4) data := buildTestScenarioBinary(t, c0, nil) s, err := ParseScenarioBinary(data) if err != nil { t.Fatalf("unexpected error: %v", err) } want := []string{"テスト", "日本語"} got := s.Chunk0.Subheader.Strings for i := range want { if got[i].Resolve("") != want[i] { t.Errorf("[%d]: got %q, want %q", i, got[i].Resolve(""), want[i]) } } } // ── compile tests ───────────────────────────────────────────────────────────── func TestCompileScenarioJSON_Subheader(t *testing.T) { input := &ScenarioJSON{ Chunk0: &ScenarioChunk0JSON{ Subheader: &ScenarioSubheaderJSON{ Type: 0x01, Unknown1: 0x00, Unknown2: 0x00, Metadata: "AAAABBBB", // base64 of 6 zero bytes Strings: []LocalizedString{NewLocalizedPlain("Hello"), NewLocalizedPlain("World")}, }, }, } jsonData, err := json.Marshal(input) if err != nil { t.Fatalf("marshal: %v", err) } compiled, err := CompileScenarioJSON(jsonData, "") if err != nil { t.Fatalf("CompileScenarioJSON: %v", err) } // Parse the compiled output and verify strings survive result, err := ParseScenarioBinary(compiled) if err != nil { t.Fatalf("ParseScenarioBinary on compiled output: %v", err) } if result.Chunk0 == nil || result.Chunk0.Subheader == nil { t.Fatal("expected chunk0 subheader in compiled output") } want := []string{"Hello", "World"} got := result.Chunk0.Subheader.Strings for i := range want { if i >= len(got) || got[i].Resolve("") != want[i] { t.Errorf("[%d]: got %q, want %q", i, got[i].Resolve(""), want[i]) } } } func TestCompileScenarioJSON_Inline(t *testing.T) { input := &ScenarioJSON{ Chunk0: &ScenarioChunk0JSON{ Inline: []ScenarioInlineEntry{ {Index: 1, Text: NewLocalizedPlain("Sword")}, {Index: 2, Text: NewLocalizedPlain("Shield")}, }, }, } jsonData, _ := json.Marshal(input) compiled, err := CompileScenarioJSON(jsonData, "") if err != nil { t.Fatalf("CompileScenarioJSON: %v", err) } result, err := ParseScenarioBinary(compiled) if err != nil { t.Fatalf("ParseScenarioBinary: %v", err) } if result.Chunk0 == nil || len(result.Chunk0.Inline) != 2 { t.Fatal("expected 2 inline entries") } if got := result.Chunk0.Inline[0].Text.Resolve(""); got != "Sword" { t.Errorf("got %q, want Sword", got) } if got := result.Chunk0.Inline[1].Text.Resolve(""); got != "Shield" { t.Errorf("got %q, want Shield", got) } } // ── round-trip tests ───────────────────────────────────────────────────────── func TestScenarioRoundTrip_Subheader(t *testing.T) { original := buildTestScenarioBinary(t, buildTestSubheaderChunk(t, []string{"QuestName", "Description"}, 0x14), buildTestSubheaderChunk(t, []string{"Dialog1", "Dialog2", "Dialog3"}, 0x2C), ) s, err := ParseScenarioBinary(original) if err != nil { t.Fatalf("parse: %v", err) } jsonData, err := json.Marshal(s) if err != nil { t.Fatalf("marshal: %v", err) } compiled, err := CompileScenarioJSON(jsonData, "") if err != nil { t.Fatalf("compile: %v", err) } // Re-parse compiled and compare strings wantStrings := []string{"QuestName", "Description", "Dialog1", "Dialog2", "Dialog3"} gotStrings := extractStringsFromScenario(t, compiled) if len(gotStrings) != len(wantStrings) { t.Fatalf("string count: got %d, want %d", len(gotStrings), len(wantStrings)) } for i := range wantStrings { if gotStrings[i] != wantStrings[i] { t.Errorf("[%d]: got %q, want %q", i, gotStrings[i], wantStrings[i]) } } } func TestScenarioRoundTrip_Inline(t *testing.T) { original := buildTestScenarioBinary(t, buildTestInlineChunk(t, []string{"EpisodeA", "EpisodeB"}), nil, ) s, _ := ParseScenarioBinary(original) jsonData, _ := json.Marshal(s) compiled, err := CompileScenarioJSON(jsonData, "") if err != nil { t.Fatalf("compile: %v", err) } got := extractStringsFromScenario(t, compiled) want := []string{"EpisodeA", "EpisodeB"} for i := range want { if i >= len(got) || got[i] != want[i] { t.Errorf("[%d]: got %q, want %q", i, got[i], want[i]) } } } func TestScenarioRoundTrip_MetadataPreserved(t *testing.T) { // The metadata block must survive parse → JSON → compile unchanged. metaBytes := []byte{0x01, 0x02, 0x03, 0x04, 0xFF, 0xFE, 0xFD, 0xFC} // Build a chunk with custom metadata and unknown field values by hand. var buf bytes.Buffer str := []byte("A\x00\xFF") totalSize := 8 + len(metaBytes) + len(str) buf.WriteByte(0x01) buf.WriteByte(0x00) buf.WriteByte(byte(totalSize)) buf.WriteByte(byte(totalSize >> 8)) buf.WriteByte(0x01) // entry count buf.WriteByte(0xAA) // unknown1 buf.WriteByte(byte(len(metaBytes))) buf.WriteByte(0xBB) // unknown2 buf.Write(metaBytes) buf.Write(str) c0 := buf.Bytes() data := buildTestScenarioBinary(t, c0, nil) s, err := ParseScenarioBinary(data) if err != nil { t.Fatalf("parse: %v", err) } sh := s.Chunk0.Subheader if sh.Type != 0x01 || sh.Unknown1 != 0xAA || sh.Unknown2 != 0xBB { t.Errorf("header fields: type=%02X unk1=%02X unk2=%02X", sh.Type, sh.Unknown1, sh.Unknown2) } // Compile and parse again — metadata must survive jsonData, _ := json.Marshal(s) compiled, err := CompileScenarioJSON(jsonData, "") if err != nil { t.Fatalf("compile: %v", err) } s2, err := ParseScenarioBinary(compiled) if err != nil { t.Fatalf("re-parse: %v", err) } sh2 := s2.Chunk0.Subheader if sh2.Metadata != sh.Metadata { t.Errorf("metadata changed:\n before: %s\n after: %s", sh.Metadata, sh2.Metadata) } if sh2.Unknown1 != sh.Unknown1 || sh2.Unknown2 != sh.Unknown2 { t.Errorf("unknown fields changed: unk1 %02X→%02X unk2 %02X→%02X", sh.Unknown1, sh2.Unknown1, sh.Unknown2, sh2.Unknown2) } } // ── real-file round-trip tests ──────────────────────────────────────────────── // scenarioBinPath is the relative path from the package to the scenario files. // These tests are skipped if the directory does not exist (CI without game data). const scenarioBinPath = "../../bin/scenarios" func TestScenarioRoundTrip_RealFiles(t *testing.T) { samples := []struct { name string wantC0 bool // expect chunk0 subheader wantC1 bool // expect chunk1 (subheader or JKR) }{ // cat=0 basic quest scenarios (chunk0 subheader, no chunk1) {"0_0_0_0_S0_T101_C0", true, false}, {"0_0_0_0_S1_T101_C0", true, false}, {"0_0_0_0_S5_T101_C0", true, false}, // cat=1 GR scenarios (chunk0 subheader, T101 has no chunk1) {"1_0_0_0_S0_T101_C0", true, false}, {"1_0_0_0_S1_T101_C0", true, false}, // cat=3 item exchange (chunk0 subheader, chunk1 subheader with extra data) {"3_0_0_0_S0_T103_C0", true, true}, // multi-chapter file with chunk1 subheader {"0_0_0_0_S0_T103_C0", true, true}, } for _, tc := range samples { tc := tc t.Run(tc.name, func(t *testing.T) { path := scenarioBinPath + "/" + tc.name + ".bin" original, err := os.ReadFile(path) if err != nil { t.Skipf("scenario file not found (game data not present): %v", err) } // Parse binary → JSON schema parsed, err := ParseScenarioBinary(original) if err != nil { t.Fatalf("ParseScenarioBinary: %v", err) } // Verify expected chunk presence if tc.wantC0 && (parsed.Chunk0 == nil || parsed.Chunk0.Subheader == nil) { t.Error("expected chunk0 subheader") } if tc.wantC1 && parsed.Chunk1 == nil { t.Error("expected chunk1") } // Marshal to JSON jsonData, err := json.Marshal(parsed) if err != nil { t.Fatalf("json.Marshal: %v", err) } // Compile JSON → binary compiled, err := CompileScenarioJSON(jsonData, "") if err != nil { t.Fatalf("CompileScenarioJSON: %v", err) } // Re-parse compiled output result, err := ParseScenarioBinary(compiled) if err != nil { t.Fatalf("ParseScenarioBinary on compiled output: %v", err) } // Verify strings survive round-trip unchanged origStrings := extractStringsFromScenario(t, original) gotStrings := extractStringsFromScenario(t, compiled) if len(gotStrings) != len(origStrings) { t.Fatalf("string count changed: %d → %d", len(origStrings), len(gotStrings)) } for i := range origStrings { if gotStrings[i] != origStrings[i] { t.Errorf("[%d]: %q → %q", i, origStrings[i], gotStrings[i]) } } // Verify metadata is preserved byte-for-byte if parsed.Chunk0 != nil && parsed.Chunk0.Subheader != nil { if result.Chunk0 == nil || result.Chunk0.Subheader == nil { t.Fatal("chunk0 subheader lost in round-trip") } if result.Chunk0.Subheader.Metadata != parsed.Chunk0.Subheader.Metadata { t.Errorf("chunk0 metadata changed after round-trip") } } if parsed.Chunk1 != nil && parsed.Chunk1.Subheader != nil { if result.Chunk1 == nil || result.Chunk1.Subheader == nil { t.Fatal("chunk1 subheader lost in round-trip") } if result.Chunk1.Subheader.Metadata != parsed.Chunk1.Subheader.Metadata { t.Errorf("chunk1 metadata changed after round-trip") } } }) } } // ── Phase C: localized scenario strings (#188) ─────────────────────────────── // TestCompileScenarioJSON_LocalizedStrings exercises the LocalizedString // schema inside scenario subheader and inline chunks — the same plain-or-map // extension shipped for quests in phase B. func TestCompileScenarioJSON_LocalizedStrings(t *testing.T) { input := &ScenarioJSON{ Chunk0: &ScenarioChunk0JSON{ Subheader: &ScenarioSubheaderJSON{ Type: 0x01, Metadata: "AAAABBBB", Strings: []LocalizedString{ mustLocalized(t, `{"jp":"クエスト","en":"Quest","fr":"Quete"}`), mustLocalized(t, `"Plain String"`), }, }, }, } jsonData, err := json.Marshal(input) if err != nil { t.Fatalf("marshal: %v", err) } // English request picks the en variant; the plain string stays plain. compiledEN, err := CompileScenarioJSON(jsonData, "en") if err != nil { t.Fatalf("compile en: %v", err) } gotEN := extractStringsFromScenario(t, compiledEN) wantEN := []string{"Quest", "Plain String"} if len(gotEN) != len(wantEN) { t.Fatalf("en string count: got %d, want %d", len(gotEN), len(wantEN)) } for i := range wantEN { if gotEN[i] != wantEN[i] { t.Errorf("en [%d]: got %q, want %q", i, gotEN[i], wantEN[i]) } } // Japanese request picks the jp variant; plain still plain. compiledJP, err := CompileScenarioJSON(jsonData, "jp") if err != nil { t.Fatalf("compile jp: %v", err) } gotJP := extractStringsFromScenario(t, compiledJP) wantJP := []string{"クエスト", "Plain String"} for i := range wantJP { if gotJP[i] != wantJP[i] { t.Errorf("jp [%d]: got %q, want %q", i, gotJP[i], wantJP[i]) } } // Spanish not provided → falls back to jp (the canonical fallback). compiledES, err := CompileScenarioJSON(jsonData, "es") if err != nil { t.Fatalf("compile es: %v", err) } gotES := extractStringsFromScenario(t, compiledES) if gotES[0] != "クエスト" { t.Errorf("es fallback = %q, want jp fallback %q", gotES[0], "クエスト") } } func mustLocalized(t *testing.T, src string) LocalizedString { t.Helper() var ls LocalizedString if err := json.Unmarshal([]byte(src), &ls); err != nil { t.Fatalf("unmarshal %q: %v", src, err) } return ls }