diff --git a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/file-edit.tsx b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/file-edit.tsx index 8c27540..4c0d889 100644 --- a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/file-edit.tsx +++ b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/file-edit.tsx @@ -25,6 +25,7 @@ import { getItemFile, getItemCombinationDataFile, getQuestFile, + getZoneDataFile, getMaps, type ItemFileNameEncoding, } from '@/lib/api'; @@ -36,6 +37,7 @@ import { DropFileEdit } from './drop-file-edit'; import { ItemFileEdit } from './item-file-edit'; import { ItemCombinationDataFileEdit } from './item-combination-data-file-edit'; import { QuestFileEdit } from './quest-file-edit'; +import { ZoneDataFileEdit } from './zone-data-file-edit'; import { queryKeys } from '@/constants'; interface FileEditProps { @@ -121,6 +123,16 @@ export function FileEdit({ filePath }: FileEditProps) { enabled: !!filePath && fileType === 'a3_quest_file' && canEditFiles, }); + const { + data: zoneDataFileData, + isLoading: zoneDataFileLoading, + error: zoneDataFileError, + } = useQuery({ + queryKey: queryKeys.zoneDataFile(filePath), + queryFn: () => getZoneDataFile({ path: filePath }), + enabled: !!filePath && fileType === 'a3_zone_data_file' && canEditFiles, + }); + const { data: dropFileData, isLoading: dropFileLoading, @@ -230,6 +242,7 @@ export function FileEdit({ filePath }: FileEditProps) { itemFileError, itemCombinationDataFileError, questFileError, + zoneDataFileError, ]); return ( @@ -278,7 +291,8 @@ export function FileEdit({ filePath }: FileEditProps) { dropFileError || itemFileError || itemCombinationDataFileError || - questFileError) && ( + questFileError || + zoneDataFileError) && ( {fileContentErrorMessage} @@ -458,6 +472,22 @@ export function FileEdit({ filePath }: FileEditProps) { )} )} + {fileType === 'a3_zone_data_file' && ( + <> + {zoneDataFileLoading && ( +
+ +
+ )} + {zoneDataFileData && !zoneDataFileError && ( + + )} + + )} {fileType && fileType !== 'text_file' && fileType !== 'a3_npc_file' && @@ -465,7 +495,8 @@ export function FileEdit({ filePath }: FileEditProps) { fileType !== 'a3_drop_file' && !isItemFileType(fileType) && fileType !== 'a3_item_combination_data_file' && - fileType !== 'a3_quest_file' && ( + fileType !== 'a3_quest_file' && + fileType !== 'a3_zone_data_file' && ( File type "{fileType}" is not yet supported for editing. diff --git a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/file-view.tsx b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/file-view.tsx index 2faed84..8a79dd1 100644 --- a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/file-view.tsx +++ b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/file-view.tsx @@ -39,6 +39,7 @@ import { getItemFile, getItemCombinationDataFile, getQuestFile, + getZoneDataFile, getRevisionSummary, createFileDownloadLink, revertFile, @@ -53,6 +54,7 @@ import { DropFileView } from '@/components/drop-file-view'; import { ItemFileView } from '@/components/item-file-view'; import { ItemCombinationDataFileView } from '@/components/item-combination-data-file-view'; import { QuestFileView } from '@/components/quest-file-view'; +import { ZoneDataFileView } from '@/components/zone-data-file-view'; import { toast } from 'sonner'; import { queryKeys } from '@/constants'; @@ -141,6 +143,16 @@ export function FileView({ filePath }: FileViewProps) { enabled: !!filePath && fileType === 'a3_quest_file', }); + const { + data: zoneDataFileData, + isLoading: zoneDataFileLoading, + error: zoneDataFileError, + } = useQuery({ + queryKey: queryKeys.zoneDataFile(filePath), + queryFn: () => getZoneDataFile({ path: filePath }), + enabled: !!filePath && fileType === 'a3_zone_data_file', + }); + const { data: dropFileData, isLoading: dropFileLoading, @@ -244,6 +256,9 @@ export function FileView({ filePath }: FileViewProps) { queryClient.invalidateQueries({ queryKey: queryKeys.questFile(filePath), }); + queryClient.invalidateQueries({ + queryKey: queryKeys.zoneDataFile(filePath), + }); queryClient.invalidateQueries({ queryKey: queryKeys.fileTree(filePath), }); @@ -319,6 +334,7 @@ export function FileView({ filePath }: FileViewProps) { itemFileError, itemCombinationDataFileError, questFileError, + zoneDataFileError, ]); const getDirectoryPath = (filePath: string): string => { @@ -427,7 +443,8 @@ export function FileView({ filePath }: FileViewProps) { dropFileError || itemFileError || itemCombinationDataFileError || - questFileError) && ( + questFileError || + zoneDataFileError) && ( {fileContentErrorMessage} @@ -668,6 +685,19 @@ export function FileView({ filePath }: FileViewProps) { )} + {fileType === 'a3_zone_data_file' && ( + <> + {zoneDataFileLoading && ( +
+ +
+ )} + {zoneDataFileData && !zoneDataFileError && ( + + )} + + )} + {fileType && fileType !== 'text_file' && fileType !== 'a3_npc_file' && @@ -676,13 +706,15 @@ export function FileView({ filePath }: FileViewProps) { !isItemFileType(fileType) && fileType !== 'a3_item_combination_data_file' && fileType !== 'a3_quest_file' && + fileType !== 'a3_zone_data_file' && !textFileLoading && !npcFileLoading && !spawnFileLoading && !dropFileLoading && !itemFileLoading && !itemCombinationDataFileLoading && - !questFileLoading && ( + !questFileLoading && + !zoneDataFileLoading && ( File type "{fileType}" is not yet supported for diff --git a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/zone-data-file-edit.tsx b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/zone-data-file-edit.tsx new file mode 100644 index 0000000..a029692 --- /dev/null +++ b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/zone-data-file-edit.tsx @@ -0,0 +1,521 @@ +import { useMemo, useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Loader2, Save } from 'lucide-react'; +import { toast } from 'sonner'; +import { APIError, updateZoneDataFile } from '@/lib/api'; +import type { ZoneDataField, ZoneDataFile, ZoneDataOperation } from '@/lib/api'; +import { queryKeys } from '@/constants'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { ZoneMapCanvas, fieldsForScope } from './zone-data-file-view'; + +const pageSize = 25; + +export function ZoneDataFileEdit({ + filePath, + defaultData, +}: { + filePath: string; + defaultData: ZoneDataFile; +}) { + const [operations, setOperations] = useState([]); + const queryClient = useQueryClient(); + const mutation = useMutation({ + mutationFn: () => + updateZoneDataFile( + { path: filePath }, + { source_hash: defaultData.source_hash, operations }, + ), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.zoneDataFile(filePath), + }); + queryClient.invalidateQueries({ queryKey: queryKeys.fileTree(filePath) }); + queryClient.invalidateQueries({ + queryKey: queryKeys.revisionSummary(filePath), + }); + toast.success('ZoneData file updated'); + setOperations([]); + }, + onError: (error) => { + toast.error( + error instanceof APIError + ? error.getErrorMessage() + : error instanceof Error + ? error.message + : 'Failed to update ZoneData file', + ); + }, + }); + + const setValue = ( + scope: ZoneDataField['scope'], + row: number, + field: string, + value: number | string | boolean, + ) => { + setOperations((current) => { + const next = current.filter( + (operation) => + !( + operation.scope === scope && + operation.row === row && + operation.field === field + ), + ); + next.push({ scope, row, field, value }); + return next; + }); + }; + + const valueFor = ( + scope: ZoneDataField['scope'], + row: number, + field: string, + original: number | string | boolean, + ) => + operations.find( + (operation) => + operation.scope === scope && + operation.row === row && + operation.field === field, + )?.value ?? original; + + return ( +
+ {defaultData.map ? ( + + ) : ( + + )} +
+
+
{operations.length} pending changes
+
+ Opaque bytes stay read-only and are checked before saving. +
+
+ +
+
+ ); +} + +function ZoneDataTableEdit({ + data, + setValue, + valueFor, +}: { + data: ZoneDataFile; + setValue: SetValue; + valueFor: ValueFor; +}) { + const [page, setPage] = useState(0); + const fields = fieldsForScope(data, 'row'); + const allRows = data.rows ?? []; + const pageCount = Math.max(1, Math.ceil(allRows.length / pageSize)); + const rows = allRows.slice(page * pageSize, (page + 1) * pageSize); + + return ( + + + Decoded fields + + +
+ + + + Row + {fields.map((field) => ( + + {field.label} + + ))} + Opaque bytes + + + + {rows.map((row) => ( + + + {row.index} + + {fields.map((field) => ( + + + setValue('row', row.index, field.key, value) + } + /> + + ))} + + + {row.opaque_bytes} + + + + ))} + +
+
+
+ + Page {page + 1} of {pageCount} + +
+ + +
+
+
+
+ ); +} + +function ZoneMapEdit({ + data, + operations, + setValue, + valueFor, +}: ZoneMapEditProps) { + const map = data.map!; + const [selectedCell, setSelectedCell] = useState(0); + const [zoom, setZoom] = useState(2); + const patchedCells = useMemo(() => { + const cells = [...map.cells]; + for (const operation of operations) { + if (operation.scope !== 'cell') { + continue; + } + + const raw = cells[operation.row] ?? 0; + if (operation.field === 'can_move') { + cells[operation.row] = operation.value ? raw | 1 : raw & ~1; + } + if (operation.field === 'pk_level') { + cells[operation.row] = + (raw & ~(3 << 15)) | (Number(operation.value) << 15); + } + if (operation.field === 'warp_index') { + cells[operation.row] = + (raw & ~(15 << 11)) | (Number(operation.value) << 11); + } + } + return cells; + }, [map.cells, operations]); + const raw = patchedCells[selectedCell] ?? 0; + const x = selectedCell % map.width; + const y = Math.floor(selectedCell / map.width); + + return ( +
+ + +
+ + + setValue('map', 0, 'name', event.target.value) + } + className="mt-2 w-28" + /> +
+
+ + {zoom}× + +
+
+ +
+ +
+
+
+
+ + + + Cell {x}, {y} + + + +
+ + setValue('cell', selectedCell, 'can_move', checked === true) + } + /> + +
+ >> 15) & 3} + min={0} + max={3} + onChange={(value) => + setValue('cell', selectedCell, 'pk_level', value) + } + /> + >> 11) & 15} + min={0} + max={15} + onChange={(value) => + setValue('cell', selectedCell, 'warp_index', value) + } + /> +
+ +
+ 0x{raw.toString(16).padStart(8, '0')} +
+
+
+
+ + + Warps + + + {map.warps.map((warp) => ( +
+ {fieldsForScope(data, 'warp').map((field) => { + const value = parseNumberInput( + String( + valueFor( + 'warp', + warp.index, + field.key, + warp.values[field.key] ?? 0, + ), + ), + 0, + ); + + return ( +
+ + + setValue( + 'warp', + warp.index, + field.key, + parseNumberInput(event.target.value, value), + ) + } + /> +
+ ); + })} +
+ ))} +
+
+
+
+ ); +} + +function FieldInput({ + field, + value, + onChange, +}: { + field: ZoneDataField; + value: number | string | boolean; + onChange: (value: number | string | boolean) => void; +}) { + if (field.type === 'boolean') { + return ( + onChange(checked === true)} + aria-label={field.label} + /> + ); + } + + return ( + + onChange( + field.type === 'integer' + ? parseNumberInput(event.target.value, Number(value)) + : event.target.value, + ) + } + /> + ); +} + +function LabeledNumber({ + id, + label, + value, + min, + max, + onChange, +}: LabeledNumberProps) { + return ( +
+ + + onChange(parseNumberInput(event.target.value, value)) + } + className="mt-2" + /> +
+ ); +} + +function parseNumberInput(value: string, fallback: number) { + const safeFallback = Number.isFinite(fallback) ? fallback : 0; + + if (value.trim() === '') { + return safeFallback; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : safeFallback; +} + +type SetValue = ( + scope: ZoneDataField['scope'], + row: number, + field: string, + value: number | string | boolean, +) => void; + +type ValueFor = ( + scope: ZoneDataField['scope'], + row: number, + field: string, + original: number | string | boolean, +) => number | string | boolean; + +interface ZoneMapEditProps { + data: ZoneDataFile; + operations: ZoneDataOperation[]; + setValue: SetValue; + valueFor: ValueFor; +} + +interface LabeledNumberProps { + id: string; + label: string; + value: number; + min: number; + max: number; + onChange: (value: number) => void; +} diff --git a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/zone-data-file-view.tsx b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/zone-data-file-view.tsx new file mode 100644 index 0000000..9bb015c --- /dev/null +++ b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/zone-data-file-view.tsx @@ -0,0 +1,356 @@ +import { + memo, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import type { ZoneDataFile, ZoneDataField } from '@/lib/api'; + +const pageSize = 50; + +export function ZoneDataFileView({ data }: { data: ZoneDataFile }) { + if (data.map) { + return ; + } + + return ; +} + +function ZoneDataTable({ data }: { data: ZoneDataFile }) { + const [query, setQuery] = useState(''); + const [page, setPage] = useState(0); + const deferredQuery = useDeferredValue(query.toLowerCase()); + const fields = data.schema.filter((field) => field.scope === 'row'); + const filteredRows = useMemo(() => { + if (!deferredQuery) { + return data.rows ?? []; + } + + return (data.rows ?? []).filter((row) => + JSON.stringify(row.values).toLowerCase().includes(deferredQuery), + ); + }, [data.rows, deferredQuery]); + const pageCount = Math.max(1, Math.ceil(filteredRows.length / pageSize)); + const currentPage = Math.min(page, pageCount - 1); + const rows = filteredRows.slice( + currentPage * pageSize, + (currentPage + 1) * pageSize, + ); + + return ( + + +
+ {formatName(data.format)} +

+ {filteredRows.length.toLocaleString()} decoded rows +

+
+ { + setQuery(event.target.value); + setPage(0); + }} + aria-label="Search decoded rows" + placeholder="Search rows" + className="sm:max-w-xs" + /> +
+ +
+ + + + Row + {fields.map((field) => ( + + {field.label} + + ))} + Opaque bytes + + + + {rows.map((row) => ( + + + {row.index} + + {fields.map((field) => ( + + {String(row.values[field.key] ?? '')} + + ))} + + + {row.opaque_bytes} + + + + ))} + +
+
+
+ + Page {currentPage + 1} of {pageCount} + +
+ + +
+
+
+
+ ); +} + +function ZoneMapView({ data }: { data: ZoneDataFile }) { + const map = data.map!; + const [selectedCell, setSelectedCell] = useState(0); + const [zoom, setZoom] = useState(2); + const raw = map.cells[selectedCell] ?? 0; + const x = selectedCell % map.width; + const y = Math.floor(selectedCell / map.width); + const warpIndex = (raw >>> 11) & 0x0f; + + return ( +
+ + +
+ {map.name || 'Zone map'} +

+ {map.width}×{map.height} cells · {map.warps.length} warps +

+
+
+ + {zoom}× + +
+
+ +
+ +
+
+
+
+ + + Selected cell + + + + + + + >> 15) & 0x03} /> + + + + + + Warp overlay + + + {map.warps.length === 0 && ( +

No warp rows.

+ )} + {map.warps.map((warp) => ( +
+ Warp {warp.index} + + Map {String(warp.values.map_id)} · Cell{' '} + {String(warp.values.cell)} + +
+ ))} +
+
+
+
+ ); +} + +export const ZoneMapCanvas = memo(function ZoneMapCanvas({ + cells, + width, + height, + selectedCell, + zoom, + onSelect, +}: { + cells: number[]; + width: number; + height: number; + selectedCell: number; + zoom: number; + onSelect: (index: number) => void; +}) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + const context = canvas?.getContext('2d'); + if (!canvas || !context) { + return; + } + + const image = context.createImageData(width, height); + for (let index = 0; index < cells.length; index += 1) { + const raw = cells[index] ?? 0; + const warp = ((raw >>> 11) & 0x0f) !== 15; + const movable = (raw & 1) !== 0; + const offset = index * 4; + image.data[offset] = warp ? 245 : movable ? 83 : 30; + image.data[offset + 1] = warp ? 158 : movable ? 178 : 41; + image.data[offset + 2] = warp ? 11 : movable ? 109 : 59; + image.data[offset + 3] = 255; + } + context.putImageData(image, 0, 0); + context.strokeStyle = '#ffffff'; + context.lineWidth = 1; + context.strokeRect( + selectedCell % width, + Math.floor(selectedCell / width), + 1, + 1, + ); + }, [cells, height, selectedCell, width]); + + const selectFromPointer = (clientX: number, clientY: number) => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + const rect = canvas.getBoundingClientRect(); + const x = Math.min( + width - 1, + Math.max(0, Math.floor(((clientX - rect.left) / rect.width) * width)), + ); + const y = Math.min( + height - 1, + Math.max(0, Math.floor(((clientY - rect.top) / rect.height) * height)), + ); + onSelect(y * width + x); + }; + + return ( + selectFromPointer(event.clientX, event.clientY)} + onKeyDown={(event) => { + const delta = + event.key === 'ArrowLeft' + ? -1 + : event.key === 'ArrowRight' + ? 1 + : event.key === 'ArrowUp' + ? -width + : event.key === 'ArrowDown' + ? width + : 0; + if (delta !== 0) { + event.preventDefault(); + onSelect( + Math.min(cells.length - 1, Math.max(0, selectedCell + delta)), + ); + } + }} + /> + ); +}); + +function MapValue({ label, value }: { label: string; value: string | number }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function formatName(value: string) { + return value + .split('_') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +export function fieldsForScope( + data: ZoneDataFile, + scope: ZoneDataField['scope'], +) { + return data.schema.filter((field) => field.scope === scope); +} diff --git a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/constants.ts b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/constants.ts index dccd387..079b3f9 100644 --- a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/constants.ts +++ b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/constants.ts @@ -42,6 +42,7 @@ export const queryKeys = { itemCombinationDataFile: (path: string) => ['item-combination-data-file', path] as const, questFile: (path: string) => ['quest-file', path] as const, + zoneDataFile: (path: string) => ['zone-data-file', path] as const, revisionSummary: (path: string) => ['revision-summary', path] as const, users: (page: number, pageSize: number, search?: string) => { if (search !== undefined && search !== '') { diff --git a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/lib/api.ts b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/lib/api.ts index f19b937..e1a1cf4 100644 --- a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/lib/api.ts +++ b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/lib/api.ts @@ -20,6 +20,7 @@ export const API_ROUTES = { ITEM_FILE: '/api/file-tree/item-file', ITEM_COMBINATION_DATA_FILE: '/api/file-tree/item-combination-data', QUEST_FILE: '/api/file-tree/quest-file', + ZONE_DATA_FILE: '/api/file-tree/zone-data-file', REVERT_FILE: '/api/file-tree/revert-file', DUPLICATE_FILE: '/api/file-tree/duplicate-file', REVISION_COUNT: '/api/file-tree/revision-summary', @@ -268,6 +269,7 @@ type FileNode = { | 'a3_it3_item_file' | 'a3_item_combination_data_file' | 'a3_map_file' + | 'a3_zone_data_file' | 'a3_spawn_file' | 'a3_unknown_file' | 'text_file' @@ -300,6 +302,7 @@ const FileNodeSchema: z.ZodType = z.lazy(() => 'a3_it3_item_file', 'a3_item_combination_data_file', 'a3_map_file', + 'a3_zone_data_file', 'a3_spawn_file', 'a3_quest_file', 'a3_unknown_file', @@ -356,6 +359,10 @@ export interface GetQuestFileParams { path: string; } +export interface GetZoneDataFileParams { + path: string; +} + const UpdateFileResponseSchema = z.object({ message: z.string(), revision_id: z.number().int(), @@ -363,6 +370,92 @@ const UpdateFileResponseSchema = z.object({ export type UpdateFileResponse = z.infer; +const ZoneDataFieldSchema = z.object({ + key: z.string(), + label: z.string(), + type: z.enum(['integer', 'boolean', 'string']), + scope: z.enum(['row', 'map', 'warp', 'cell']), + editable: z.boolean(), + min: z.number().optional(), + max: z.number().optional(), +}); + +const ZoneDataValueSchema = z.union([z.number(), z.string(), z.boolean()]); + +const ZoneDataRowSchema = z.object({ + index: z.number().int().nonnegative(), + values: z.record(z.string(), ZoneDataValueSchema), + opaque_bytes: z.string(), +}); + +const ZoneDataMapSchema = z.object({ + name: z.string(), + warps: z.array(ZoneDataRowSchema), + cells: z.array(z.number().int().nonnegative()), + width: z.number().int().positive(), + height: z.number().int().positive(), + trailing_opaque_bytes: z.string(), +}); + +const ZoneDataFileSchema = z.object({ + format: z.enum([ + 'zone_map', + 'npc_skill', + 'npc_favor', + 'pc_data', + 'skill_data', + 'skill_delay', + 'passive_skill', + 'hired_soldier_skill', + 'cash_item', + 'set_item', + 'present_item_set', + 'pet', + 'shue_combination', + 'lottery', + 'derby_gift', + 'event_item_reward', + 'a3_present', + 'message', + 'quest_ex', + 'squest_quiz', + 'tower_treasure', + 'ox_quiz', + 'tyr_base', + 'tyr_portal', + 'tyr_upgrade', + 'tyr_start_point', + 'tyr_gift', + 'tyr_npc_regen', + 'tyr_skill_layer', + ]), + source_hash: z.string(), + schema: z.array(ZoneDataFieldSchema), + rows: z.array(ZoneDataRowSchema).optional(), + map: ZoneDataMapSchema.optional(), + capabilities: z.object({ + update_fields: z.boolean(), + insert_rows: z.boolean(), + delete_rows: z.boolean(), + }), +}); + +export type ZoneDataField = z.infer; +export type ZoneDataRow = z.infer; +export type ZoneDataFile = z.infer; + +export interface ZoneDataOperation { + scope: ZoneDataField['scope']; + row: number; + field: string; + value: number | string | boolean; +} + +export interface ZoneDataUpdateRequest { + source_hash: string; + operations: ZoneDataOperation[]; +} + const RevisionSummaryResponseSchema = z.object({ count: z.number().int().nonnegative(), last_revision_at: z.number().int().nullable().optional(), @@ -1201,6 +1294,35 @@ export async function updateQuestFile( ); } +export async function getZoneDataFile( + params: GetZoneDataFileParams, +): Promise { + const response = await axiosInstance.get(API_ROUTES.ZONE_DATA_FILE, { + params, + }); + return validateResponse( + ZoneDataFileSchema, + response.data, + API_ROUTES.ZONE_DATA_FILE, + ); +} + +export async function updateZoneDataFile( + params: GetZoneDataFileParams, + data: ZoneDataUpdateRequest, +): Promise { + const response = await axiosInstance.put( + API_ROUTES.ZONE_DATA_FILE, + data, + { params }, + ); + return validateResponse( + UpdateFileResponseSchema, + response.data, + API_ROUTES.ZONE_DATA_FILE, + ); +} + export async function revertFile( params: GetTextFileParams, ): Promise { diff --git a/go.mod b/go.mod index 127e870..3b58393 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 github.com/microsoft/go-mssqldb v1.10.0 - github.com/project-agonyl/agonyl-utils-go v0.4.1 + github.com/project-agonyl/agonyl-utils-go v0.7.1 github.com/robfig/cron/v3 v3.0.1 github.com/rs/zerolog v1.34.0 github.com/shirou/gopsutil/v3 v3.24.5 diff --git a/go.sum b/go.sum index dda3989..52096c6 100644 --- a/go.sum +++ b/go.sum @@ -88,8 +88,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/project-agonyl/agonyl-utils-go v0.4.1 h1:609noqDqjmdgdHWsw1r4wT3Z5JYelsm41+uH8IfkqaE= -github.com/project-agonyl/agonyl-utils-go v0.4.1/go.mod h1:Inqyrm2CytanE8+gE4ehLo7E5NXF8AG1OVNAebgI+bU= +github.com/project-agonyl/agonyl-utils-go v0.7.1 h1:rGOQodFwZbz31kKBJW8c/XbkS5OE8AxXU7RvzchT+DA= +github.com/project-agonyl/agonyl-utils-go v0.7.1/go.mod h1:Inqyrm2CytanE8+gE4ehLo7E5NXF8AG1OVNAebgI+bU= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= diff --git a/internal/server/file_system_routes.go b/internal/server/file_system_routes.go index 3435ae4..c9b6a76 100644 --- a/internal/server/file_system_routes.go +++ b/internal/server/file_system_routes.go @@ -58,6 +58,8 @@ func (s *Server) InitializeFileSystemRoutes(r *chi.Mux) { r.Put("/item-combination-data", s.handleUpdateItemCombinationDataFile) r.Get("/quest-file", s.handleQuestFileData) r.Put("/quest-file", s.handleUpdateQuestFile) + r.Get("/zone-data-file", s.handleZoneDataFile) + r.Put("/zone-data-file", s.handleUpdateZoneDataFile) r.Post("/revert-file", s.handleRevertFile) r.Post("/duplicate-file", s.handleDuplicateFile) r.Get("/revision-summary", s.handleRevisionSummary) @@ -79,12 +81,13 @@ func (s *Server) handleFileTree(w http.ResponseWriter, r *http.Request) { var rootNode *FileNode var err error + zoneRoot, _ := s.zoneDataService.ResolveRoot() if pathParam == "" { - rootNode, err = s.getSystemRoots(showDotfiles) + rootNode, err = s.getSystemRoots(showDotfiles, zoneRoot) } else { cleanPath := filepath.Clean(pathParam) - rootNode, err = s.getDirectoryNode(cleanPath, showDotfiles) + rootNode, err = s.getDirectoryNode(cleanPath, showDotfiles, zoneRoot) } if err != nil { @@ -113,7 +116,7 @@ func (s *Server) handleFileTree(w http.ResponseWriter, r *http.Request) { _ = utils.WriteJSONResponse(w, response) } -func (s *Server) getSystemRoots(showDotfiles bool) (*FileNode, error) { +func (s *Server) getSystemRoots(showDotfiles bool, zoneRoot string) (*FileNode, error) { hostname, _ := s.fileEditor.Hostname() if hostname == "" { hostname = "A3 Online Server" @@ -160,7 +163,7 @@ func (s *Server) getSystemRoots(showDotfiles bool) (*FileNode, error) { continue } - node := s.createNodeFromEntry(rootPath, entry, 1) + node := s.createNodeFromEntry(rootPath, entry, 1, zoneRoot) root.Children = append(root.Children, node) } } @@ -168,7 +171,7 @@ func (s *Server) getSystemRoots(showDotfiles bool) (*FileNode, error) { return root, nil } -func (s *Server) getDirectoryNode(path string, showDotfiles bool) (*FileNode, error) { +func (s *Server) getDirectoryNode(path string, showDotfiles bool, zoneRoot string) (*FileNode, error) { info, err := s.fileEditor.Stat(path) if err != nil { return nil, err @@ -203,7 +206,7 @@ func (s *Server) getDirectoryNode(path string, showDotfiles bool) (*FileNode, er continue } - child := s.createNodeFromEntry(path, entry, 1) + child := s.createNodeFromEntry(path, entry, 1, zoneRoot) node.Children = append(node.Children, child) } } @@ -211,16 +214,13 @@ func (s *Server) getDirectoryNode(path string, showDotfiles bool) (*FileNode, er node.FileSize = info.Size() node.FileExtension = filepath.Ext(name) node.MimeType = mime.TypeByExtension(node.FileExtension) - node.FileType = s.fileEditor.GetFileType(path, info) - node.IsEditable = s.fileEditor.IsFileEditable(path, info) - node.IsViewable = s.fileEditor.IsFileViewable(path, info) - node.APIEndpoint = s.fileEditor.GetFileAPIEndpoint(path, info) + s.applyFileNodeMetadata(node, path, info, zoneRoot) } return node, nil } -func (s *Server) createNodeFromEntry(parentPath string, entry fs.DirEntry, depth int) *FileNode { +func (s *Server) createNodeFromEntry(parentPath string, entry fs.DirEntry, depth int, zoneRoot string) *FileNode { kind := "file" if entry.IsDir() { kind = "directory" @@ -245,16 +245,176 @@ func (s *Server) createNodeFromEntry(parentPath string, entry fs.DirEntry, depth node.FileSize = info.Size() node.FileExtension = filepath.Ext(entry.Name()) node.MimeType = mime.TypeByExtension(node.FileExtension) - node.FileType = s.fileEditor.GetFileType(fullPath, info) - node.IsEditable = s.fileEditor.IsFileEditable(fullPath, info) - node.IsViewable = s.fileEditor.IsFileViewable(fullPath, info) - node.APIEndpoint = s.fileEditor.GetFileAPIEndpoint(fullPath, info) + s.applyFileNodeMetadata(node, fullPath, info, zoneRoot) } } return node } +func (s *Server) applyFileNodeMetadata(node *FileNode, path string, info fs.FileInfo, zoneRoot string) { + if zoneRoot != "" && services.IsZoneDataCandidatePath(path) { + if _, ok := s.zoneDataService.DetectResolved(zoneRoot, path); ok { + node.FileType = services.FileTypeZoneData + node.IsEditable = true + node.IsViewable = true + node.APIEndpoint = "/file-tree/zone-data-file" + return + } + } + + node.FileType = s.fileEditor.GetFileType(path, info) + node.IsEditable = s.fileEditor.IsFileEditable(path, info) + node.IsViewable = s.fileEditor.IsFileViewable(path, info) + node.APIEndpoint = s.fileEditor.GetFileAPIEndpoint(path, info) +} + +func (s *Server) handleZoneDataFile(w http.ResponseWriter, r *http.Request) { + if !s.requireUserPermission(w, r, permissions.ActionViewFiles) { + return + } + + cleanPath, _, format, ok := s.validateZoneDataPath(w, r) + if !ok { + return + } + + data, err := s.zoneDataService.Read(cleanPath, format) + if err != nil { + _ = utils.WriteJSONResponseWithStatus(w, http.StatusBadRequest, map[string]interface{}{ + "errorCode": constants.ErrorCodeFileReadError, + "context": "file-system", + "errors": []string{"Failed to decode ZoneData file: " + err.Error()}, + }) + return + } + + _ = utils.WriteJSONResponse(w, data) +} + +func (s *Server) handleUpdateZoneDataFile(w http.ResponseWriter, r *http.Request) { + if !s.requireUserPermission(w, r, permissions.ActionEditFiles) { + return + } + + userID, ok := utils.GetUserIdFromContext(r.Context()) + if !ok { + _ = utils.WriteJSONResponseWithStatus(w, http.StatusUnauthorized, map[string]interface{}{ + "errorCode": constants.ErrorCodeUnauthorized, + "context": "file-system", + "errors": []string{"User ID not found in context"}, + }) + return + } + + cleanPath, info, format, ok := s.validateZoneDataPath(w, r) + if !ok { + return + } + + var request ZoneDataUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + _ = utils.WriteJSONResponseWithStatus(w, http.StatusBadRequest, map[string]interface{}{ + "errorCode": constants.ErrorCodeBadRequest, + "context": "file-system", + "errors": []string{"Invalid request body: " + err.Error()}, + }) + return + } + + if request.SourceHash == "" || len(request.Operations) == 0 { + _ = utils.WriteJSONResponseWithStatus(w, http.StatusBadRequest, map[string]interface{}{ + "errorCode": constants.ErrorCodeBadRequest, + "context": "file-system", + "errors": []string{"source_hash and at least one operation are required"}, + }) + return + } + + ctx := &fileUpdateContext{userID: userID, cleanPath: cleanPath, info: info, fileID: utils.GenerateMD5Hash(cleanPath)} + revisionID, ok := s.updateFileWithRevision(w, ctx, func() ([]byte, func() error, bool) { + original, err := s.fileEditor.ReadFile(cleanPath) + if err != nil { + writeZoneDataError(w, http.StatusInternalServerError, constants.ErrorCodeFileReadError, "Failed to re-read ZoneData file: "+err.Error()) + return nil, nil, false + } + + if utils.CalculateFileHash(original) != request.SourceHash { + writeZoneDataError(w, http.StatusConflict, constants.ErrorCodeBadRequest, services.ErrZoneDataStale.Error()) + return nil, nil, false + } + + updated, err := s.zoneDataService.Apply(original, format, request.Operations) + if err != nil { + writeZoneDataError(w, http.StatusBadRequest, constants.ErrorCodeBadRequest, err.Error()) + return nil, nil, false + } + + return updated, func() error { + return s.fileEditor.WriteFile(cleanPath, updated, info.Mode()) + }, true + }) + if !ok { + return + } + + _ = utils.WriteJSONResponse(w, map[string]interface{}{ + "message": "File updated successfully", + "revision_id": revisionID, + }) +} + +func (s *Server) validateZoneDataPath(w http.ResponseWriter, r *http.Request) (string, fs.FileInfo, services.ZoneDataFormat, bool) { + pathParam := r.URL.Query().Get("path") + if pathParam == "" { + writeZoneDataError(w, http.StatusBadRequest, constants.ErrorCodeBadRequest, "Path parameter is required") + return "", nil, "", false + } + + cleanPath := filepath.Clean(pathParam) + info, err := s.fileEditor.Stat(cleanPath) + if err != nil { + status := http.StatusInternalServerError + errorCode := constants.ErrorCodeFileReadError + message := "Cannot read file: " + err.Error() + if s.fileEditor.IsNotExist(err) { + status = http.StatusNotFound + errorCode = constants.ErrorCodeNotFound + message = "Path not found" + } + + writeZoneDataError(w, status, errorCode, message) + return "", nil, "", false + } + + if info.IsDir() { + writeZoneDataError(w, http.StatusBadRequest, constants.ErrorCodePathIsDirectory, "Path is a directory, not a file") + return "", nil, "", false + } + + root, err := s.zoneDataService.ResolveRoot() + if err != nil { + writeZoneDataError(w, http.StatusBadRequest, constants.ErrorCodeBadRequest, "Zone Server path is not configured") + return "", nil, "", false + } + + format, detected := s.zoneDataService.DetectResolved(root, cleanPath) + if !detected { + writeZoneDataError(w, http.StatusBadRequest, constants.ErrorCodeFileNotViewable, "File is not a supported ZoneData file") + return "", nil, "", false + } + + return cleanPath, info, format, true +} + +func writeZoneDataError(w http.ResponseWriter, status int, errorCode string, message string) { + _ = utils.WriteJSONResponseWithStatus(w, status, map[string]interface{}{ + "errorCode": errorCode, + "context": "file-system", + "errors": []string{message}, + }) +} + func (s *Server) handleNPCFileData(w http.ResponseWriter, r *http.Request) { pathParam := r.URL.Query().Get("path") @@ -3675,6 +3835,11 @@ type FileTreeResponse struct { FileTree *FileNode `json:"file_tree"` } +type ZoneDataUpdateRequest struct { + SourceHash string `json:"source_hash"` + Operations []services.ZoneDataOperation `json:"operations"` +} + type NPCFileAPIData struct { Name string `json:"name" validate:"required"` Id *uint16 `json:"id" validate:"required"` diff --git a/internal/server/file_system_zone_data_test.go b/internal/server/file_system_zone_data_test.go new file mode 100644 index 0000000..9cbe2e1 --- /dev/null +++ b/internal/server/file_system_zone_data_test.go @@ -0,0 +1,122 @@ +package server + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/omnihance/omnihance-a3-agent/internal/config" + "github.com/omnihance/omnihance-a3-agent/internal/constants" + "github.com/omnihance/omnihance-a3-agent/internal/logger" + "github.com/omnihance/omnihance-a3-agent/internal/services" + "github.com/omnihance/omnihance-a3-agent/internal/utils" + "github.com/project-agonyl/agonyl-utils-go/npcskillfile" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +func TestZoneDataFileReadAndUpdateCreatesRevision(t *testing.T) { + server, userID, filePath, original := newZoneDataTestServer(t) + + getRequest := zoneDataRequest(http.MethodGet, filePath, nil, userID, constants.RoleAdmin) + getResponse := httptest.NewRecorder() + server.handleZoneDataFile(getResponse, getRequest) + require.Equal(t, http.StatusOK, getResponse.Code, getResponse.Body.String()) + + var decoded services.ZoneDataFile + require.NoError(t, json.NewDecoder(getResponse.Body).Decode(&decoded)) + require.Equal(t, services.ZoneDataFormatNPCSkill, decoded.Format) + require.Equal(t, utils.CalculateFileHash(original), decoded.SourceHash) + + body := bytes.NewBufferString(`{"source_hash":"` + decoded.SourceHash + `","operations":[{"scope":"row","row":0,"field":"effect_code","value":48879}]}`) + putRequest := zoneDataRequest(http.MethodPut, filePath, body, userID, constants.RoleAdmin) + putResponse := httptest.NewRecorder() + server.handleUpdateZoneDataFile(putResponse, putRequest) + require.Equal(t, http.StatusOK, putResponse.Code, putResponse.Body.String()) + + updated, err := os.ReadFile(filePath) + require.NoError(t, err) + require.Equal(t, original[:14], updated[:14]) + require.Equal(t, []byte{0xef, 0xbe}, updated[14:16]) + require.Equal(t, original[16:], updated[16:]) + + revision, err := server.internalDB.GetLastCompletedFileRevision(utils.GenerateMD5Hash(filePath)) + require.NoError(t, err) + require.NotNil(t, revision) + revisionData, err := os.ReadFile(revision.RevisionPath) + require.NoError(t, err) + require.Equal(t, original, revisionData) +} + +func TestZoneDataFileUpdateRejectsStaleHash(t *testing.T) { + server, userID, filePath, original := newZoneDataTestServer(t) + body := bytes.NewBufferString(`{"source_hash":"stale","operations":[{"scope":"row","row":0,"field":"effect_code","value":1}]}`) + request := zoneDataRequest(http.MethodPut, filePath, body, userID, constants.RoleAdmin) + response := httptest.NewRecorder() + + server.handleUpdateZoneDataFile(response, request) + + require.Equal(t, http.StatusConflict, response.Code, response.Body.String()) + current, err := os.ReadFile(filePath) + require.NoError(t, err) + require.Equal(t, original, current) + revision, err := server.internalDB.GetLastCompletedFileRevision(utils.GenerateMD5Hash(filePath)) + require.NoError(t, err) + require.Nil(t, revision) +} + +func TestZoneDataFileUpdateRequiresEditPermission(t *testing.T) { + server, userID, filePath, _ := newZoneDataTestServer(t) + body := bytes.NewBufferString(`{"source_hash":"hash","operations":[{"scope":"row","row":0,"field":"effect_code","value":1}]}`) + request := zoneDataRequest(http.MethodPut, filePath, body, userID, constants.RoleUser) + response := httptest.NewRecorder() + + server.handleUpdateZoneDataFile(response, request) + + require.Equal(t, http.StatusForbidden, response.Code, response.Body.String()) +} + +func newZoneDataTestServer(t *testing.T) (*Server, int64, string, []byte) { + t.Helper() + + internalDB := newTestInternalDB(t) + user, err := internalDB.CreateUser("zone-data-admin@example.com", "password", constants.RoleAdmin, nil) + require.NoError(t, err) + root := t.TempDir() + _, err = internalDB.CreateSetting(constants.SettingKeyZoneServerPath, root, &user.ID) + require.NoError(t, err) + + filePath := filepath.Join(root, "ZoneData", "npc", "NPCSkill") + require.NoError(t, os.MkdirAll(filepath.Dir(filePath), 0755)) + original := make([]byte, npcskillfile.RecordSize) + for index := range original { + original[index] = byte(index + 1) + } + require.NoError(t, os.WriteFile(filePath, original, 0600)) + + log := logger.NewZerologLogger(zerolog.Nop(), "test", zerolog.Disabled) + fileEditor := services.NewFileEditorService(log) + server := &Server{ + cfg: &config.EnvVars{CookieSecret: "test-secret", RevisionsDirectory: t.TempDir()}, + log: log, internalDB: internalDB, fileEditor: fileEditor, + } + server.zoneDataService = services.NewZoneDataService(internalDB, fileEditor) + return server, user.ID, filePath, original +} + +func zoneDataRequest(method string, path string, body *bytes.Buffer, userID int64, role string) *http.Request { + var request *http.Request + if body == nil { + request = httptest.NewRequest(method, "/api/file-tree/zone-data-file?path="+url.QueryEscape(path), nil) + } else { + request = httptest.NewRequest(method, "/api/file-tree/zone-data-file?path="+url.QueryEscape(path), body) + } + ctx := utils.SetUserIdInContext(request.Context(), userID) + ctx = utils.SetUserRolesInContext(ctx, []string{role}) + return request.WithContext(ctx) +} diff --git a/internal/server/server.go b/internal/server/server.go index 4e89091..40bf582 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -25,6 +25,7 @@ type Server struct { versionChecker services.VersionCheckerService backupService services.BackupService serverViewService services.ServerViewService + zoneDataService services.ZoneDataService uploadManager *fileUploadManager } @@ -55,6 +56,7 @@ func NewServer( versionChecker: versionChecker, backupService: backupService, serverViewService: serverViewService, + zoneDataService: services.NewZoneDataService(internalDB, fileEditor), } newServer.uploadManager = newFileUploadManager(cfg.RevisionsDirectory, fileEditor, log, cfg.MaxFileUploadSizeBytes()) diff --git a/internal/services/file_editor_service.go b/internal/services/file_editor_service.go index bca6684..df73934 100644 --- a/internal/services/file_editor_service.go +++ b/internal/services/file_editor_service.go @@ -29,6 +29,7 @@ const ( FileTypeIT3Item FileType = "a3_it3_item_file" FileTypeItemCombinationData FileType = "a3_item_combination_data_file" FileTypeMap FileType = "a3_map_file" + FileTypeZoneData FileType = "a3_zone_data_file" FileTypeUnknown FileType = "a3_unknown_file" FileTypeSpawn FileType = "a3_spawn_file" FileTypeQuest FileType = "a3_quest_file" diff --git a/internal/services/zone_data_apply.go b/internal/services/zone_data_apply.go new file mode 100644 index 0000000..c654e95 --- /dev/null +++ b/internal/services/zone_data_apply.go @@ -0,0 +1,480 @@ +package services + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + + "github.com/project-agonyl/agonyl-utils-go/hiredsoldierskillfile" + "github.com/project-agonyl/agonyl-utils-go/npcfavorfile" + "github.com/project-agonyl/agonyl-utils-go/npcskillfile" + "github.com/project-agonyl/agonyl-utils-go/passiveskillfile" + "github.com/project-agonyl/agonyl-utils-go/pcdatafile" + "github.com/project-agonyl/agonyl-utils-go/skilldatafile" + "github.com/project-agonyl/agonyl-utils-go/skilldelayfile" + "github.com/project-agonyl/agonyl-utils-go/zonemapfile" +) + +func (s *zoneDataService) Apply(original []byte, format ZoneDataFormat, operations []ZoneDataOperation) ([]byte, error) { + allowed := make([]bool, len(original)) + var encoded []byte + var err error + switch format { + case ZoneDataFormatMap: + encoded, err = applyMap(original, operations, allowed) + case ZoneDataFormatNPCSkill: + encoded, err = applyNPCSkill(original, operations, allowed) + case ZoneDataFormatNPCFavor: + encoded, err = applyNPCFavor(original, operations, allowed) + case ZoneDataFormatPCData: + encoded, err = applyPCData(original, operations, allowed) + case ZoneDataFormatSkillData: + encoded, err = applySkillData(original, operations, allowed) + case ZoneDataFormatSkillDelay: + encoded, err = applySkillDelay(original, operations, allowed) + case ZoneDataFormatPassiveSkill: + encoded, err = applyPassiveSkill(original, operations, allowed) + case ZoneDataFormatHiredSoldierSkill: + encoded, err = applyHiredSoldierSkill(original, operations, allowed) + case ZoneDataFormatCashItem, ZoneDataFormatSetItem, ZoneDataFormatPresentItemSet, ZoneDataFormatPet, + ZoneDataFormatShueCombination, ZoneDataFormatLottery, ZoneDataFormatDerbyGift, + ZoneDataFormatEventItemReward, ZoneDataFormatA3Present: + encoded, err = applyEconomyZoneData(original, format, operations, allowed) + case ZoneDataFormatMessage, ZoneDataFormatQuestEx, ZoneDataFormatSQuestQuiz, ZoneDataFormatTowerTreasure, + ZoneDataFormatOXQuiz, ZoneDataFormatTyrBase, ZoneDataFormatTyrPortal, ZoneDataFormatTyrUpgrade, + ZoneDataFormatTyrStartPoint, ZoneDataFormatTyrGift, ZoneDataFormatTyrNPCRegen, ZoneDataFormatTyrSkillLayer: + encoded, err = applyRemainingZoneData(original, format, operations, allowed) + default: + err = fmt.Errorf("unsupported ZoneData format %q", format) + } + if err != nil { + return nil, err + } + + if err := verifyZoneData(original, encoded, allowed, format); err != nil { + return nil, err + } + + return encoded, nil +} + +func applyMap(original []byte, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + data, err := zonemapfile.Read(bytes.NewReader(original)) + if err != nil { + return nil, err + } + + meshOffset := zonemapfile.HeaderSize + len(data.Warps)*zonemapfile.WarpSize + for _, operation := range operations { + switch operation.Scope { + case "map": + if operation.Field != "name" { + return nil, fmt.Errorf("unknown map field %q", operation.Field) + } + var value string + if err := json.Unmarshal(operation.Value, &value); err != nil { + return nil, fmt.Errorf("map name must be a string") + } + if err := data.SetName(value); err != nil { + return nil, err + } + allowBytes(allowed, 20, 2) + case "warp": + if err := validateRow(operation.Row, len(data.Warps)); err != nil { + return nil, err + } + value, err := operationUint(operation, 65535) + if err != nil { + return nil, err + } + offset := zonemapfile.HeaderSize + operation.Row*zonemapfile.WarpSize + switch operation.Field { + case "map_id": + data.Warps[operation.Row].SetMapID(uint16(value)) + allowBytes(allowed, offset, 2) + case "cell": + data.Warps[operation.Row].SetCell(uint16(value)) + allowBytes(allowed, offset+2, 2) + case "unknown": + data.Warps[operation.Row].SetUnknown(uint16(value)) + allowBytes(allowed, offset+4, 2) + default: + return nil, fmt.Errorf("unknown warp field %q", operation.Field) + } + case "cell": + if err := validateRow(operation.Row, len(data.Cells)); err != nil { + return nil, err + } + raw := data.Cells[operation.Row] + switch operation.Field { + case "can_move": + var value bool + if err := json.Unmarshal(operation.Value, &value); err != nil { + return nil, fmt.Errorf("can_move must be a boolean") + } + data.Cells[operation.Row] = zonemapfile.SetCanMove(raw, value) + case "pk_level": + value, err := operationUint(operation, 3) + if err != nil { + return nil, err + } + data.Cells[operation.Row], err = zonemapfile.SetPKLevel(raw, byte(value)) + if err != nil { + return nil, err + } + case "warp_index": + value, err := operationUint(operation, 15) + if err != nil { + return nil, err + } + if value == 15 { + data.Cells[operation.Row], err = zonemapfile.SetWarpIndex(raw, nil) + } else { + index := byte(value) + data.Cells[operation.Row], err = zonemapfile.SetWarpIndex(raw, &index) + } + if err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("unknown cell field %q", operation.Field) + } + allowBytes(allowed, meshOffset+operation.Row*4, 4) + default: + return nil, fmt.Errorf("unknown operation scope %q", operation.Scope) + } + } + + return writeZoneData(func(writer io.Writer) error { return zonemapfile.Write(writer, data) }) +} + +func applyNPCSkill(original []byte, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + data, err := npcskillfile.Read(bytes.NewReader(original)) + if err != nil { + return nil, err + } + for _, operation := range operations { + if operation.Scope != "row" { + return nil, fmt.Errorf("invalid NPC skill scope %q", operation.Scope) + } + if err := validateRow(operation.Row, len(data)); err != nil { + return nil, err + } + value, err := operationUint(operation, 65535) + if err != nil { + return nil, err + } + offset := operation.Row * npcskillfile.RecordSize + switch operation.Field { + case "npc_type": + data[operation.Row].SetNPCType(uint16(value)) + allowBytes(allowed, offset, 2) + case "kind": + data[operation.Row].SetKind(byte(value)) + allowBytes(allowed, offset+2, 1) + case "attack_type": + data[operation.Row].SetAttackType(byte(value)) + allowBytes(allowed, offset+3, 1) + case "one_target_range": + data[operation.Row].SetOneTargetRange(byte(value)) + allowBytes(allowed, offset+8, 1) + case "range_radius": + data[operation.Row].SetRangeRadius(byte(value)) + allowBytes(allowed, offset+9, 1) + case "cooldown_seconds": + data[operation.Row].SetCooldownSeconds(uint16(value)) + allowBytes(allowed, offset+10, 2) + case "effect_param": + data[operation.Row].SetEffectParam(uint16(value)) + allowBytes(allowed, offset+12, 2) + case "effect_code": + data[operation.Row].SetEffectCode(uint16(value)) + allowBytes(allowed, offset+14, 2) + case "effect_value": + data[operation.Row].SetEffectValue(uint16(value)) + allowBytes(allowed, offset+16, 2) + default: + return nil, fmt.Errorf("unknown NPC skill field %q", operation.Field) + } + } + return writeZoneData(func(writer io.Writer) error { return npcskillfile.Write(writer, data) }) +} + +func applyNPCFavor(original []byte, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + data, err := npcfavorfile.Read(bytes.NewReader(original)) + if err != nil { + return nil, err + } + for _, operation := range operations { + if operation.Scope != "row" { + return nil, fmt.Errorf("invalid NPC favor scope %q", operation.Scope) + } + if err := validateRow(operation.Row, len(data)); err != nil { + return nil, err + } + offset := operation.Row * npcfavorfile.RecordSize + switch operation.Field { + case "index": + value, err := operationInt32(operation) + if err != nil { + return nil, err + } + data[operation.Row].SetIndex(value) + allowBytes(allowed, offset, 4) + case "npc_type": + value, err := operationUint(operation, 4294967295) + if err != nil { + return nil, err + } + data[operation.Row].SetNPCType(uint32(value)) + allowBytes(allowed, offset+4, 4) + default: + return nil, fmt.Errorf("unknown NPC favor field %q", operation.Field) + } + } + return writeZoneData(func(writer io.Writer) error { return npcfavorfile.Write(writer, data) }) +} + +func applyPCData(original []byte, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + data, err := pcdatafile.Read(bytes.NewReader(original)) + if err != nil { + return nil, err + } + for _, operation := range operations { + if operation.Scope != "row" || operation.Row != 0 { + return nil, fmt.Errorf("invalid PC data target") + } + index, ok := fieldIndex(operation.Field, "value_", len(pcDataLabels)) + if !ok { + return nil, fmt.Errorf("unknown PC data field %q", operation.Field) + } + value, err := operationUint(operation, 65535) + if err != nil { + return nil, err + } + if err := data.SetValue(index, uint16(value)); err != nil { + return nil, err + } + allowBytes(allowed, index*2, 2) + } + return writeZoneData(func(writer io.Writer) error { return pcdatafile.Write(writer, data) }) +} + +func applySkillData(original []byte, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + data, err := skilldatafile.Read(bytes.NewReader(original)) + if err != nil { + return nil, err + } + for _, operation := range operations { + if operation.Scope != "row" { + return nil, fmt.Errorf("invalid skill data scope %q", operation.Scope) + } + if err := validateRow(operation.Row, len(data)); err != nil { + return nil, err + } + value, err := operationUint(operation, 255) + if err != nil { + return nil, err + } + offset := operation.Row * skilldatafile.RecordSize + switch operation.Field { + case "code": + data[operation.Row].SetCode(byte(value)) + allowBytes(allowed, offset, 1) + case "type": + data[operation.Row].SetType(byte(value)) + allowBytes(allowed, offset+1, 1) + case "sub_type": + data[operation.Row].SetSubType(byte(value)) + allowBytes(allowed, offset+2, 1) + case "target_type": + data[operation.Row].SetTargetType(byte(value)) + allowBytes(allowed, offset+3, 1) + case "need_item": + data[operation.Row].SetNeedItem(byte(value)) + allowBytes(allowed, offset+4, 1) + case "reaction": + data[operation.Row].SetReaction(byte(value)) + allowBytes(allowed, offset+5, 1) + case "abnormalcy": + data[operation.Row].SetAbnormalcy(byte(value)) + allowBytes(allowed, offset+6, 1) + default: + index, ok := fieldIndex(operation.Field, "monster_rate_", 6) + if !ok { + return nil, fmt.Errorf("unknown skill data field %q", operation.Field) + } + if err := data[operation.Row].SetMonsterRate(index, byte(value)); err != nil { + return nil, err + } + allowBytes(allowed, offset+7+index, 1) + } + } + return writeZoneData(func(writer io.Writer) error { return skilldatafile.Write(writer, data) }) +} + +func applySkillDelay(original []byte, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + data, err := skilldelayfile.Read(bytes.NewReader(original)) + if err != nil { + return nil, err + } + for _, operation := range operations { + if operation.Scope != "row" { + return nil, fmt.Errorf("invalid skill delay scope %q", operation.Scope) + } + if err := validateRow(operation.Row, len(data)); err != nil { + return nil, err + } + offset := operation.Row * skilldelayfile.RecordSize + switch operation.Field { + case "class_index": + value, err := operationUint(operation, 3) + if err != nil { + return nil, err + } + data[operation.Row].SetClassIndex(byte(value)) + allowBytes(allowed, offset, 1) + case "skill_index": + value, err := operationUint(operation, 63) + if err != nil { + return nil, err + } + data[operation.Row].SetSkillIndex(uint16(value)) + allowBytes(allowed, offset+1, 2) + case "delay_ms": + value, err := operationUint(operation, 65535) + if err != nil { + return nil, err + } + data[operation.Row].SetDelayMS(uint16(value)) + allowBytes(allowed, offset+3, 2) + case "delay_info": + value, err := operationUint(operation, 255) + if err != nil { + return nil, err + } + data[operation.Row].SetDelayInfo(byte(value)) + allowBytes(allowed, offset+4, 1) + default: + return nil, fmt.Errorf("unknown skill delay field %q", operation.Field) + } + } + return writeZoneData(func(writer io.Writer) error { return skilldelayfile.Write(writer, data) }) +} + +func applyPassiveSkill(original []byte, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + data, err := passiveskillfile.Read(bytes.NewReader(original)) + if err != nil { + return nil, err + } + for _, operation := range operations { + if operation.Scope != "row" { + return nil, fmt.Errorf("invalid passive skill scope %q", operation.Scope) + } + if err := validateRow(operation.Row, len(data)); err != nil { + return nil, err + } + offset := operation.Row * passiveskillfile.RecordSize + value, err := operationUint(operation, 4294967295) + if err != nil { + return nil, err + } + switch operation.Field { + case "passive_id": + if value > 31 { + return nil, fmt.Errorf("passive_id exceeds 31") + } + data[operation.Row].SetPassiveID(uint32(value)) + allowBytes(allowed, offset, 4) + case "class_restriction": + data[operation.Row].SetClassRestriction(uint32(value)) + allowBytes(allowed, offset+4, 4) + case "effect_kind": + data[operation.Row].SetEffectKind(uint32(value)) + allowBytes(allowed, offset+8, 4) + case "level": + if value > 6 { + return nil, fmt.Errorf("level exceeds 6") + } + data[operation.Row].SetLevel(uint32(value)) + allowBytes(allowed, offset+12, 4) + case "required_points": + data[operation.Row].SetRequiredPoints(uint32(value)) + allowBytes(allowed, offset+16, 4) + case "money": + data[operation.Row].SetMoney(uint32(value)) + allowBytes(allowed, offset+20, 4) + default: + index, ok := fieldIndex(operation.Field, "effect_", passiveskillfile.EffectCount) + if !ok { + return nil, fmt.Errorf("unknown passive skill field %q", operation.Field) + } + if err := data[operation.Row].SetEffect(index, uint32(value)); err != nil { + return nil, err + } + allowBytes(allowed, offset+24+index*4, 4) + } + } + return writeZoneData(func(writer io.Writer) error { return passiveskillfile.Write(writer, data) }) +} + +func applyHiredSoldierSkill(original []byte, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + data, err := hiredsoldierskillfile.Read(bytes.NewReader(original)) + if err != nil { + return nil, err + } + for _, operation := range operations { + if operation.Scope != "row" { + return nil, fmt.Errorf("invalid hired-soldier skill scope %q", operation.Scope) + } + if err := validateRow(operation.Row, len(data)); err != nil { + return nil, err + } + parts := strings.Split(operation.Field, "_") + if len(parts) < 4 || parts[0] != "level" { + return nil, fmt.Errorf("unknown hired-soldier skill field %q", operation.Field) + } + level, err := strconv.Atoi(parts[1]) + if err != nil || level < 1 || level > hiredsoldierskillfile.LevelCount { + return nil, fmt.Errorf("invalid hired-soldier skill level") + } + field := strings.Join(parts[2:], "_") + current, _ := data[operation.Row].Level(level) + value, err := operationUint(operation, 4294967295) + if err != nil { + return nil, err + } + fieldOffset, fieldSize := 0, 0 + switch field { + case "required_item_code": + if value > 65535 { + return nil, fmt.Errorf("required item code exceeds 65535") + } + current.RequiredItemCode = uint16(value) + fieldOffset, fieldSize = 0x0a, 2 + case "skill_point_cost": + if value > 255 { + return nil, fmt.Errorf("skill point cost exceeds 255") + } + current.SkillPointCost = byte(value) + fieldOffset, fieldSize = 0x0c, 1 + case "money_cost": + current.MoneyCost = uint32(value) + fieldOffset, fieldSize = 0x0d, 4 + case "lore_cost": + current.LoreCost = uint32(value) + fieldOffset, fieldSize = 0x11, 4 + default: + return nil, fmt.Errorf("unknown hired-soldier skill field %q", operation.Field) + } + if err := data[operation.Row].SetLevel(level, current); err != nil { + return nil, err + } + offset := operation.Row*hiredsoldierskillfile.RecordSize + (level-1)*hiredsoldierskillfile.LevelStride + fieldOffset + allowBytes(allowed, offset, fieldSize) + } + return writeZoneData(func(writer io.Writer) error { return hiredsoldierskillfile.Write(writer, data) }) +} diff --git a/internal/services/zone_data_decode.go b/internal/services/zone_data_decode.go new file mode 100644 index 0000000..e8e7f92 --- /dev/null +++ b/internal/services/zone_data_decode.go @@ -0,0 +1,260 @@ +package services + +import ( + "bytes" + "encoding/base64" + "fmt" + + "github.com/project-agonyl/agonyl-utils-go/hiredsoldierskillfile" + "github.com/project-agonyl/agonyl-utils-go/npcfavorfile" + "github.com/project-agonyl/agonyl-utils-go/npcskillfile" + "github.com/project-agonyl/agonyl-utils-go/passiveskillfile" + "github.com/project-agonyl/agonyl-utils-go/pcdatafile" + "github.com/project-agonyl/agonyl-utils-go/skilldatafile" + "github.com/project-agonyl/agonyl-utils-go/skilldelayfile" + "github.com/project-agonyl/agonyl-utils-go/zonemapfile" +) + +var pcDataLabels = []string{ + "Strength", "Magic", "Dexterity", "Vitality", "Mana", "Bonus Points", "Attack", "Defense", "Magic Attack", "HP", "MP", "Hit Probability", "Damage Ratio", "Finish Ratio", +} + +func mapSchema() []ZoneDataField { + return []ZoneDataField{ + stringField("name", "Map Name", "map"), + integerField("map_id", "Destination Map", "warp", 65535), + integerField("cell", "Destination Cell", "warp", 65535), + integerField("unknown", "Unknown", "warp", 65535), + boolField("can_move", "Can Move", "cell"), + integerField("pk_level", "PK Level", "cell", 3), + integerField("warp_index", "Warp Index", "cell", 15), + } +} + +func npcSkillSchema() []ZoneDataField { + return []ZoneDataField{ + integerField("npc_type", "NPC Type", "row", 65535), integerField("kind", "Kind", "row", 255), + integerField("attack_type", "Attack Type", "row", 255), integerField("one_target_range", "One Target Range", "row", 255), + integerField("range_radius", "Range Radius", "row", 255), integerField("cooldown_seconds", "Cooldown Seconds", "row", 65535), + integerField("effect_param", "Effect Parameter", "row", 65535), integerField("effect_code", "Effect Code", "row", 65535), + integerField("effect_value", "Effect Value", "row", 65535), + } +} + +func npcFavorSchema() []ZoneDataField { + min := int64(-2147483648) + max := int64(2147483647) + index := ZoneDataField{Key: "index", Label: "Favor Index", Type: "integer", Scope: "row", Editable: true, Min: &min, Max: &max} + return []ZoneDataField{index, integerField("npc_type", "NPC Type", "row", 4294967295)} +} + +func pcDataSchema() []ZoneDataField { + fields := make([]ZoneDataField, len(pcDataLabels)) + for index, label := range pcDataLabels { + fields[index] = integerField(fmt.Sprintf("value_%d", index), label, "row", 65535) + } + + return fields +} + +func skillDataSchema() []ZoneDataField { + fields := []ZoneDataField{ + integerField("code", "Code", "row", 255), integerField("type", "Type", "row", 255), integerField("sub_type", "Sub Type", "row", 255), + integerField("target_type", "Target Type", "row", 255), integerField("need_item", "Required Item", "row", 255), + integerField("reaction", "Reaction", "row", 255), integerField("abnormalcy", "Abnormalcy", "row", 255), + } + for index := 0; index < 6; index++ { + fields = append(fields, integerField(fmt.Sprintf("monster_rate_%d", index), fmt.Sprintf("Monster Rate %d", index+1), "row", 255)) + } + + return fields +} + +func skillDelaySchema() []ZoneDataField { + return []ZoneDataField{ + integerField("class_index", "Class", "row", 3), integerField("skill_index", "Skill", "row", 63), + integerField("delay_ms", "Delay", "row", 65535), integerField("delay_info", "Delay Info", "row", 255), + } +} + +func passiveSkillSchema() []ZoneDataField { + fields := []ZoneDataField{ + integerField("passive_id", "Passive ID", "row", 31), integerField("class_restriction", "Class Restriction", "row", 255), + integerField("effect_kind", "Effect Kind", "row", 255), integerField("level", "Level", "row", 6), + integerField("required_points", "Required Points", "row", 255), integerField("money", "Money", "row", 4294967295), + } + for index := 0; index < passiveskillfile.EffectCount; index++ { + fields = append(fields, integerField(fmt.Sprintf("effect_%d", index), fmt.Sprintf("Effect %d", index+1), "row", 4294967295)) + } + + return fields +} + +func hiredSoldierSkillSchema() []ZoneDataField { + fields := make([]ZoneDataField, 0, hiredsoldierskillfile.LevelCount*4) + for level := 1; level <= hiredsoldierskillfile.LevelCount; level++ { + prefix := fmt.Sprintf("level_%d_", level) + fields = append(fields, + integerField(prefix+"required_item_code", fmt.Sprintf("Level %d Item", level), "row", 65535), + integerField(prefix+"skill_point_cost", fmt.Sprintf("Level %d Skill Points", level), "row", 255), + integerField(prefix+"money_cost", fmt.Sprintf("Level %d Money", level), "row", 4294967295), + integerField(prefix+"lore_cost", fmt.Sprintf("Level %d Lore", level), "row", 4294967295), + ) + } + + return fields +} + +func decodeMap(data []byte) (*ZoneDataMap, error) { + parsed, err := zonemapfile.Read(bytes.NewReader(data)) + if err != nil { + return nil, err + } + + warps := make([]ZoneDataRow, len(parsed.Warps)) + for index, warp := range parsed.Warps { + warps[index] = row(index, warp.Raw[:], map[string]any{"map_id": warp.MapID(), "cell": warp.Cell(), "unknown": warp.Unknown()}) + } + + cells := make([]uint32, len(parsed.Cells)) + copy(cells, parsed.Cells[:]) + return &ZoneDataMap{ + Name: parsed.Name(), Warps: warps, Cells: cells, Width: zonemapfile.Width, Height: zonemapfile.Height, + Trailing: base64Bytes(parsed.Trailing), + }, nil +} + +func decodeNPCSkill(data []byte) ([]ZoneDataRow, error) { + parsed, err := npcskillfile.Read(bytes.NewReader(data)) + if err != nil { + return nil, err + } + + rows := make([]ZoneDataRow, len(parsed)) + for index, record := range parsed { + rows[index] = row(index, record.Raw[:], map[string]any{ + "npc_type": record.NPCType(), "kind": record.Kind(), "attack_type": record.AttackType(), "one_target_range": record.OneTargetRange(), + "range_radius": record.RangeRadius(), "cooldown_seconds": record.CooldownSeconds(), "effect_param": record.EffectParam(), + "effect_code": record.EffectCode(), "effect_value": record.EffectValue(), + }) + } + + return rows, nil +} + +func decodeNPCFavor(data []byte) ([]ZoneDataRow, error) { + parsed, err := npcfavorfile.Read(bytes.NewReader(data)) + if err != nil { + return nil, err + } + + rows := make([]ZoneDataRow, len(parsed)) + for index, record := range parsed { + rows[index] = row(index, record.Raw[:], map[string]any{"index": record.Index(), "npc_type": record.NPCType()}) + } + + return rows, nil +} + +func decodePCData(data []byte) ([]ZoneDataRow, error) { + parsed, err := pcdatafile.Read(bytes.NewReader(data)) + if err != nil { + return nil, err + } + + values := make(map[string]any, len(pcDataLabels)) + for index := range pcDataLabels { + values[fmt.Sprintf("value_%d", index)], _ = parsed.Value(index) + } + + return []ZoneDataRow{row(0, parsed.Raw[:], values)}, nil +} + +func decodeSkillData(data []byte) ([]ZoneDataRow, error) { + parsed, err := skilldatafile.Read(bytes.NewReader(data)) + if err != nil { + return nil, err + } + + rows := make([]ZoneDataRow, len(parsed)) + for index, record := range parsed { + values := map[string]any{ + "code": record.Code(), "type": record.Type(), "sub_type": record.SubType(), "target_type": record.TargetType(), + "need_item": record.NeedItem(), "reaction": record.Reaction(), "abnormalcy": record.Abnormalcy(), + } + for rateIndex := 0; rateIndex < 6; rateIndex++ { + values[fmt.Sprintf("monster_rate_%d", rateIndex)], _ = record.MonsterRate(rateIndex) + } + rows[index] = row(index, record.Raw[:], values) + } + + return rows, nil +} + +func decodeSkillDelay(data []byte) ([]ZoneDataRow, error) { + parsed, err := skilldelayfile.Read(bytes.NewReader(data)) + if err != nil { + return nil, err + } + + rows := make([]ZoneDataRow, len(parsed)) + for index, record := range parsed { + rows[index] = row(index, record.Raw[:], map[string]any{ + "class_index": record.ClassIndex(), "skill_index": record.SkillIndex(), "delay_ms": record.DelayMS(), "delay_info": record.DelayInfo(), + }) + } + + return rows, nil +} + +func decodePassiveSkill(data []byte) ([]ZoneDataRow, error) { + parsed, err := passiveskillfile.Read(bytes.NewReader(data)) + if err != nil { + return nil, err + } + + rows := make([]ZoneDataRow, len(parsed)) + for index, record := range parsed { + values := map[string]any{ + "passive_id": record.PassiveID(), "class_restriction": record.ClassRestriction(), "effect_kind": record.EffectKind(), + "level": record.Level(), "required_points": record.RequiredPoints(), "money": record.Money(), + } + for effectIndex := 0; effectIndex < passiveskillfile.EffectCount; effectIndex++ { + values[fmt.Sprintf("effect_%d", effectIndex)], _ = record.Effect(effectIndex) + } + rows[index] = row(index, record.Raw[:], values) + } + + return rows, nil +} + +func decodeHiredSoldierSkill(data []byte) ([]ZoneDataRow, error) { + parsed, err := hiredsoldierskillfile.Read(bytes.NewReader(data)) + if err != nil { + return nil, err + } + + rows := make([]ZoneDataRow, len(parsed)) + for index, record := range parsed { + values := make(map[string]any, hiredsoldierskillfile.LevelCount*4) + for level := 1; level <= hiredsoldierskillfile.LevelCount; level++ { + value, _ := record.Level(level) + prefix := fmt.Sprintf("level_%d_", level) + values[prefix+"required_item_code"] = value.RequiredItemCode + values[prefix+"skill_point_cost"] = value.SkillPointCost + values[prefix+"money_cost"] = value.MoneyCost + values[prefix+"lore_cost"] = value.LoreCost + } + rows[index] = row(index, record.Raw[:], values) + } + + return rows, nil +} + +func base64Bytes(data []byte) string { + if len(data) == 0 { + return "" + } + + return base64.StdEncoding.EncodeToString(data) +} diff --git a/internal/services/zone_data_economy_apply.go b/internal/services/zone_data_economy_apply.go new file mode 100644 index 0000000..4bceb09 --- /dev/null +++ b/internal/services/zone_data_economy_apply.go @@ -0,0 +1,517 @@ +package services + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + + "github.com/project-agonyl/agonyl-utils-go/a3presentfile" + "github.com/project-agonyl/agonyl-utils-go/cashitemfile" + "github.com/project-agonyl/agonyl-utils-go/derbygiftfile" + "github.com/project-agonyl/agonyl-utils-go/eventitemrewardfile" + "github.com/project-agonyl/agonyl-utils-go/lotteryfile" + "github.com/project-agonyl/agonyl-utils-go/petfile" + "github.com/project-agonyl/agonyl-utils-go/presentitemsetfile" + "github.com/project-agonyl/agonyl-utils-go/setitemfile" + "github.com/project-agonyl/agonyl-utils-go/shuecombinationfile" +) + +func applyEconomyZoneData(original []byte, format ZoneDataFormat, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + switch format { + case ZoneDataFormatCashItem: + return applyCashItems(original, operations, allowed) + case ZoneDataFormatSetItem: + return applySetItems(original, operations, allowed) + case ZoneDataFormatPresentItemSet: + return applyPresentItemSets(original, operations, allowed) + case ZoneDataFormatPet: + return applyPets(original, operations, allowed) + case ZoneDataFormatShueCombination: + return applyShueCombinations(original, operations, allowed) + case ZoneDataFormatLottery: + return applyLottery(original, operations, allowed) + case ZoneDataFormatDerbyGift: + return applyDerbyGifts(original, operations, allowed) + case ZoneDataFormatEventItemReward: + return applyEventItemRewards(original, operations, allowed) + case ZoneDataFormatA3Present: + return applyA3Presents(original, operations, allowed) + default: + return nil, fmt.Errorf("unsupported economy ZoneData format %q", format) + } +} + +func applyCashItems(original []byte, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + data, err := cashitemfile.Read(bytes.NewReader(original)) + if err != nil { + return nil, err + } + for _, operation := range operations { + if err := validateRowOperation(operation, len(data)); err != nil { + return nil, err + } + value, err := operationUint(operation, 4294967295) + if err != nil { + return nil, err + } + offset := operation.Row * cashitemfile.RecordSize + switch operation.Field { + case "npc_type": + if value > 65535 { + return nil, fmt.Errorf("npc_type exceeds 65535") + } + data[operation.Row].SetNPCType(uint16(value)) + allowBytes(allowed, offset, 2) + case "item_code": + if value > 65535 { + return nil, fmt.Errorf("item_code exceeds 65535") + } + data[operation.Row].SetItemCode(uint16(value)) + allowBytes(allowed, offset+2, 2) + case "price": + data[operation.Row].SetPrice(uint32(value)) + allowBytes(allowed, offset+4, 4) + case "count": + if value > 65535 { + return nil, fmt.Errorf("count exceeds 65535") + } + data[operation.Row].SetCount(uint16(value)) + allowBytes(allowed, offset+8, 2) + default: + return nil, fmt.Errorf("unknown cash item field %q", operation.Field) + } + } + return writeZoneData(func(w io.Writer) error { return cashitemfile.Write(w, data) }) +} + +func applySetItems(original []byte, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + data, err := setitemfile.Read(bytes.NewReader(original)) + if err != nil { + return nil, err + } + for _, operation := range operations { + if err := validateRowOperation(operation, len(data)); err != nil { + return nil, err + } + value, err := operationUint(operation, 65535) + if err != nil { + return nil, err + } + parts := strings.Split(operation.Field, "_") + recordOffset := operation.Row * setitemfile.RecordSize + if len(parts) == 4 && parts[0] == "piece" { + index, parseErr := strconv.Atoi(parts[1]) + if parseErr != nil || index < 0 || index >= setitemfile.PieceCount { + return nil, fmt.Errorf("unknown set item field %q", operation.Field) + } + wearSlot, code, _ := data[operation.Row].Piece(index) + switch strings.Join(parts[2:], "_") { + case "wear_slot": + if value > 255 { + return nil, fmt.Errorf("wear slot exceeds 255") + } + wearSlot = byte(value) + allowBytes(allowed, recordOffset+index*3, 1) + case "code": + code = uint16(value) + allowBytes(allowed, recordOffset+index*3+1, 2) + default: + return nil, fmt.Errorf("unknown set item field %q", operation.Field) + } + if err := data[operation.Row].SetPiece(index, wearSlot, code); err != nil { + return nil, err + } + continue + } + if len(parts) == 5 && parts[0] == "bonus" { + pieceCount, firstErr := strconv.Atoi(parts[1]) + index, secondErr := strconv.Atoi(parts[2]) + if firstErr != nil || secondErr != nil { + return nil, fmt.Errorf("unknown set item field %q", operation.Field) + } + optionID, currentValue, ok := data[operation.Row].Bonus(pieceCount, index) + if !ok { + return nil, fmt.Errorf("unknown set item field %q", operation.Field) + } + offset := recordOffset + 0x1e + (pieceCount-1)*0x1e + index*3 + switch strings.Join(parts[3:], "_") { + case "option_id": + if value > 255 { + return nil, fmt.Errorf("option id exceeds 255") + } + optionID = byte(value) + allowBytes(allowed, offset, 1) + case "value": + currentValue = uint16(value) + allowBytes(allowed, offset+1, 2) + default: + return nil, fmt.Errorf("unknown set item field %q", operation.Field) + } + if err := data[operation.Row].SetBonus(pieceCount, index, optionID, currentValue); err != nil { + return nil, err + } + continue + } + return nil, fmt.Errorf("unknown set item field %q", operation.Field) + } + return writeZoneData(func(w io.Writer) error { return setitemfile.Write(w, data) }) +} + +func applyPresentItemSets(original []byte, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + data, err := presentitemsetfile.Read(bytes.NewReader(original)) + if err != nil { + return nil, err + } + for _, operation := range operations { + if err := validateRowOperation(operation, len(data)); err != nil { + return nil, err + } + value, err := operationUint(operation, 65535) + if err != nil { + return nil, err + } + offset := operation.Row * presentitemsetfile.RecordSize + if operation.Field == "present_set_code" { + data[operation.Row].SetPresentSetCode(uint16(value)) + allowBytes(allowed, offset, 2) + continue + } + index, part, ok := indexedPairField(operation.Field, "reward_", presentitemsetfile.RewardCount) + if !ok { + return nil, fmt.Errorf("unknown present item set field %q", operation.Field) + } + count, itemCode, _ := data[operation.Row].Reward(index) + switch part { + case "count": + count = uint16(value) + allowBytes(allowed, offset+2+index*4, 2) + case "item_code": + itemCode = uint16(value) + allowBytes(allowed, offset+4+index*4, 2) + default: + return nil, fmt.Errorf("unknown present item set field %q", operation.Field) + } + if err := data[operation.Row].SetReward(index, count, itemCode); err != nil { + return nil, err + } + } + return writeZoneData(func(w io.Writer) error { return presentitemsetfile.Write(w, data) }) +} + +func applyPets(original []byte, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + data, err := petfile.Read(bytes.NewReader(original)) + if err != nil { + return nil, err + } + for _, operation := range operations { + if err := validateRowOperation(operation, len(data)); err != nil { + return nil, err + } + offset := operation.Row * petfile.RecordSize + if operation.Field == "name" { + value, err := operationString(operation) + if err != nil { + return nil, err + } + if err := data[operation.Row].SetName(value); err != nil { + return nil, err + } + allowBytes(allowed, offset+4, petfile.NameSize) + continue + } + value, err := operationUint(operation, 4294967295) + if err != nil { + return nil, err + } + switch operation.Field { + case "group": + if value > 65535 { + return nil, fmt.Errorf("group exceeds 65535") + } + data[operation.Row].SetGroup(uint16(value)) + allowBytes(allowed, offset, 2) + case "code": + if value > petfile.CodeMax { + return nil, fmt.Errorf("code exceeds %d", petfile.CodeMax) + } + if err := data[operation.Row].SetCode(uint16(value)); err != nil { + return nil, err + } + allowBytes(allowed, offset+2, 2) + case "price": + data[operation.Row].SetPrice(uint32(value)) + allowBytes(allowed, offset+36, 4) + case "auth": + if value > 65535 { + return nil, fmt.Errorf("auth exceeds 65535") + } + data[operation.Row].SetAuth(uint16(value)) + allowBytes(allowed, offset+40, 2) + case "limit": + if value > 65535 { + return nil, fmt.Errorf("limit exceeds 65535") + } + data[operation.Row].SetLimit(uint16(value)) + allowBytes(allowed, offset+42, 2) + case "data_1": + if value > 65535 { + return nil, fmt.Errorf("data_1 exceeds 65535") + } + data[operation.Row].SetData1(uint16(value)) + allowBytes(allowed, offset+44, 2) + case "data_2": + if value > 65535 { + return nil, fmt.Errorf("data_2 exceeds 65535") + } + data[operation.Row].SetData2(uint16(value)) + allowBytes(allowed, offset+46, 2) + default: + return nil, fmt.Errorf("unknown pet field %q", operation.Field) + } + } + return writeZoneData(func(w io.Writer) error { return petfile.Write(w, data) }) +} + +func applyShueCombinations(original []byte, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + data, err := shuecombinationfile.Read(bytes.NewReader(original)) + if err != nil { + return nil, err + } + for _, operation := range operations { + if err := validateRowOperation(operation, len(data)); err != nil { + return nil, err + } + index, ok := fieldIndex(operation.Field, "field_", shuecombinationfile.FieldCount) + if !ok { + return nil, fmt.Errorf("unknown Shue combination field %q", operation.Field) + } + value, err := operationUint(operation, 65535) + if err != nil { + return nil, err + } + if err := data[operation.Row].SetField(index, uint16(value)); err != nil { + return nil, err + } + allowBytes(allowed, operation.Row*shuecombinationfile.RecordSize+index*2, 2) + } + return writeZoneData(func(w io.Writer) error { return shuecombinationfile.Write(w, data) }) +} + +func applyLottery(original []byte, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + data, err := lotteryfile.Read(bytes.NewReader(original)) + if err != nil { + return nil, err + } + for _, operation := range operations { + if err := validateRowOperation(operation, len(data)); err != nil { + return nil, err + } + offset := operation.Row * lotteryfile.RecordSize + if index, ok := fieldIndex(operation.Field, "message_", lotteryfile.MessageCount); ok { + value, err := operationString(operation) + if err != nil { + return nil, err + } + if err := data[operation.Row].SetMessage(index, value); err != nil { + return nil, err + } + allowBytes(allowed, offset+4+index*lotteryfile.MessageSize, lotteryfile.MessageSize) + continue + } + value, err := operationUint(operation, 65535) + if err != nil { + return nil, err + } + switch operation.Field { + case "event_item_id": + if value > 255 { + return nil, fmt.Errorf("event_item_id exceeds 255") + } + data[operation.Row].SetEventItemID(byte(value)) + allowBytes(allowed, offset, 1) + case "enabled": + if value > 255 { + return nil, fmt.Errorf("enabled exceeds 255") + } + data[operation.Row].SetEnabled(byte(value)) + allowBytes(allowed, offset+1, 1) + case "reward_item_code": + data[operation.Row].SetRewardItemCode(uint16(value)) + allowBytes(allowed, offset+2, 2) + default: + return nil, fmt.Errorf("unknown lottery field %q", operation.Field) + } + } + return writeZoneData(func(w io.Writer) error { return lotteryfile.Write(w, data) }) +} + +func applyDerbyGifts(original []byte, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + data, err := derbygiftfile.Read(bytes.NewReader(original)) + if err != nil { + return nil, err + } + for _, operation := range operations { + if err := validateRowOperation(operation, len(data)); err != nil { + return nil, err + } + value, err := operationUint(operation, 4294967295) + if err != nil { + return nil, err + } + offset := operation.Row * derbygiftfile.RecordSize + switch operation.Field { + case "item_code": + data[operation.Row].SetItemCode(uint32(value)) + allowBytes(allowed, offset, 4) + case "quantity": + data[operation.Row].SetQuantity(uint32(value)) + allowBytes(allowed, offset+4, 4) + case "weight": + if value > derbygiftfile.MaxProbability { + return nil, fmt.Errorf("weight exceeds %d", derbygiftfile.MaxProbability) + } + data[operation.Row].SetWeight(uint32(value)) + allowBytes(allowed, offset+8, 4) + default: + return nil, fmt.Errorf("unknown derby gift field %q", operation.Field) + } + } + return writeZoneData(func(w io.Writer) error { return derbygiftfile.Write(w, data) }) +} + +func applyEventItemRewards(original []byte, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + data, err := eventitemrewardfile.Read(bytes.NewReader(original)) + if err != nil { + return nil, err + } + for _, operation := range operations { + if err := validateRowOperation(operation, len(data)); err != nil { + return nil, err + } + offset := operation.Row * eventitemrewardfile.RecordSize + if operation.Field == "message" { + value, err := operationString(operation) + if err != nil { + return nil, err + } + if err := data[operation.Row].SetMessage(value); err != nil { + return nil, err + } + allowBytes(allowed, offset+4, eventitemrewardfile.MessageSize) + continue + } + value, err := operationUint(operation, 65535) + if err != nil { + return nil, err + } + switch operation.Field { + case "item_code": + data[operation.Row].SetItemCode(uint16(value)) + allowBytes(allowed, offset, 2) + case "weight": + data[operation.Row].SetWeight(uint16(value)) + allowBytes(allowed, offset+2, 2) + default: + return nil, fmt.Errorf("unknown event item reward field %q", operation.Field) + } + } + return writeZoneData(func(w io.Writer) error { return eventitemrewardfile.Write(w, data) }) +} + +func applyA3Presents(original []byte, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + data, err := a3presentfile.Read(bytes.NewReader(original)) + if err != nil { + return nil, err + } + for _, operation := range operations { + if err := validateRowOperation(operation, len(data)); err != nil { + return nil, err + } + offset := operation.Row * a3presentfile.RecordSize + if operation.Field == "name" { + value, err := operationString(operation) + if err != nil { + return nil, err + } + if err := data[operation.Row].SetName(value); err != nil { + return nil, err + } + allowBytes(allowed, offset, a3presentfile.NameSize) + continue + } + value, err := operationUint(operation, 4294967295) + if err != nil { + return nil, err + } + if index, part, ok := indexedPairField(operation.Field, "reward_", a3presentfile.RewardCount); ok { + if value > 65535 { + return nil, fmt.Errorf("reward value exceeds 65535") + } + count, itemCode, _ := data[operation.Row].Reward(index) + switch part { + case "count": + count = uint16(value) + allowBytes(allowed, offset+a3presentfile.NameSize+index*4, 2) + case "item_code": + itemCode = uint16(value) + allowBytes(allowed, offset+a3presentfile.NameSize+index*4+2, 2) + default: + return nil, fmt.Errorf("unknown A3 present field %q", operation.Field) + } + if err := data[operation.Row].SetReward(index, count, itemCode); err != nil { + return nil, err + } + continue + } + switch operation.Field { + case "money": + data[operation.Row].SetMoney(uint32(value)) + allowBytes(allowed, offset+0x21, 4) + case "lore": + data[operation.Row].SetLore(uint32(value)) + allowBytes(allowed, offset+0x25, 4) + case "experience": + data[operation.Row].SetExperience(uint32(value)) + allowBytes(allowed, offset+0x29, 4) + case "offered": + if value > 65535 { + return nil, fmt.Errorf("offered exceeds 65535") + } + data[operation.Row].SetOffered(uint16(value)) + allowBytes(allowed, offset+0x2d, 2) + default: + return nil, fmt.Errorf("unknown A3 present field %q", operation.Field) + } + } + return writeZoneData(func(w io.Writer) error { return a3presentfile.Write(w, data) }) +} + +func validateRowOperation(operation ZoneDataOperation, length int) error { + if operation.Scope != "row" { + return fmt.Errorf("invalid operation scope %q", operation.Scope) + } + return validateRow(operation.Row, length) +} + +func operationString(operation ZoneDataOperation) (string, error) { + var value string + if err := json.Unmarshal(operation.Value, &value); err != nil { + return "", fmt.Errorf("%s must be a string", operation.Field) + } + return value, nil +} + +func indexedPairField(field string, prefix string, count int) (int, string, bool) { + if !strings.HasPrefix(field, prefix) { + return 0, "", false + } + parts := strings.SplitN(strings.TrimPrefix(field, prefix), "_", 2) + if len(parts) != 2 { + return 0, "", false + } + index, err := strconv.Atoi(parts[0]) + return index, parts[1], err == nil && index >= 0 && index < count +} diff --git a/internal/services/zone_data_economy_decode.go b/internal/services/zone_data_economy_decode.go new file mode 100644 index 0000000..899654d --- /dev/null +++ b/internal/services/zone_data_economy_decode.go @@ -0,0 +1,247 @@ +package services + +import ( + "bytes" + "fmt" + + "github.com/project-agonyl/agonyl-utils-go/a3presentfile" + "github.com/project-agonyl/agonyl-utils-go/cashitemfile" + "github.com/project-agonyl/agonyl-utils-go/derbygiftfile" + "github.com/project-agonyl/agonyl-utils-go/eventitemrewardfile" + "github.com/project-agonyl/agonyl-utils-go/lotteryfile" + "github.com/project-agonyl/agonyl-utils-go/petfile" + "github.com/project-agonyl/agonyl-utils-go/presentitemsetfile" + "github.com/project-agonyl/agonyl-utils-go/setitemfile" + "github.com/project-agonyl/agonyl-utils-go/shuecombinationfile" +) + +var shueCombinationLabels = []string{ + "Base Rune 1", "Base Rune 2", "Base Rune 3", "Rune 1", "Rune 2", "Rune 3", "Item 1", "Item 2", "Item 3", "Object Item", "Success Ratio", "Success Item", "Option Type", "Failure", "Reserved 1", "Reserved 2", +} + +func decodeEconomyZoneData(data []byte, format ZoneDataFormat) ([]ZoneDataField, []ZoneDataRow, error) { + switch format { + case ZoneDataFormatCashItem: + return decodeCashItems(data) + case ZoneDataFormatSetItem: + return decodeSetItems(data) + case ZoneDataFormatPresentItemSet: + return decodePresentItemSets(data) + case ZoneDataFormatPet: + return decodePets(data) + case ZoneDataFormatShueCombination: + return decodeShueCombinations(data) + case ZoneDataFormatLottery: + return decodeLottery(data) + case ZoneDataFormatDerbyGift: + return decodeDerbyGifts(data) + case ZoneDataFormatEventItemReward: + return decodeEventItemRewards(data) + case ZoneDataFormatA3Present: + return decodeA3Presents(data) + default: + return nil, nil, fmt.Errorf("unsupported economy ZoneData format %q", format) + } +} + +func decodeCashItems(data []byte) ([]ZoneDataField, []ZoneDataRow, error) { + parsed, err := cashitemfile.Read(bytes.NewReader(data)) + if err != nil { + return nil, nil, err + } + + fields := []ZoneDataField{ + integerField("npc_type", "NPC Type", "row", 65535), integerField("item_code", "Item Code", "row", 65535), + integerField("price", "Price", "row", 4294967295), integerField("count", "Count", "row", 65535), + } + rows := make([]ZoneDataRow, len(parsed)) + for index, record := range parsed { + rows[index] = row(index, record.Raw[:], map[string]any{"npc_type": record.NPCType(), "item_code": record.ItemCode(), "price": record.Price(), "count": record.Count()}) + } + + return fields, rows, nil +} + +func decodeSetItems(data []byte) ([]ZoneDataField, []ZoneDataRow, error) { + parsed, err := setitemfile.Read(bytes.NewReader(data)) + if err != nil { + return nil, nil, err + } + + fields := make([]ZoneDataField, 0, setitemfile.PieceCount*2+(setitemfile.BonusPieceCount-1)*setitemfile.BonusCount*2) + for index := 0; index < setitemfile.PieceCount; index++ { + fields = append(fields, + integerField(fmt.Sprintf("piece_%d_wear_slot", index), fmt.Sprintf("Piece %d Wear Slot", index+1), "row", 255), + integerField(fmt.Sprintf("piece_%d_code", index), fmt.Sprintf("Piece %d Code", index+1), "row", 65535), + ) + } + for pieceCount := 1; pieceCount < setitemfile.BonusPieceCount; pieceCount++ { + for index := 0; index < setitemfile.BonusCount; index++ { + fields = append(fields, + integerField(fmt.Sprintf("bonus_%d_%d_option_id", pieceCount, index), fmt.Sprintf("%d Pieces Bonus %d Option", pieceCount, index+1), "row", 255), + integerField(fmt.Sprintf("bonus_%d_%d_value", pieceCount, index), fmt.Sprintf("%d Pieces Bonus %d Value", pieceCount, index+1), "row", 65535), + ) + } + } + rows := make([]ZoneDataRow, len(parsed)) + for rowIndex, record := range parsed { + values := make(map[string]any, len(fields)) + for index := 0; index < setitemfile.PieceCount; index++ { + wearSlot, code, _ := record.Piece(index) + values[fmt.Sprintf("piece_%d_wear_slot", index)] = wearSlot + values[fmt.Sprintf("piece_%d_code", index)] = code + } + for pieceCount := 1; pieceCount < setitemfile.BonusPieceCount; pieceCount++ { + for index := 0; index < setitemfile.BonusCount; index++ { + optionID, value, _ := record.Bonus(pieceCount, index) + values[fmt.Sprintf("bonus_%d_%d_option_id", pieceCount, index)] = optionID + values[fmt.Sprintf("bonus_%d_%d_value", pieceCount, index)] = value + } + } + rows[rowIndex] = row(rowIndex, record.Raw[:], values) + } + + return fields, rows, nil +} + +func decodePresentItemSets(data []byte) ([]ZoneDataField, []ZoneDataRow, error) { + parsed, err := presentitemsetfile.Read(bytes.NewReader(data)) + if err != nil { + return nil, nil, err + } + + fields := []ZoneDataField{integerField("present_set_code", "Present Set Code", "row", 65535)} + for index := 0; index < presentitemsetfile.RewardCount; index++ { + fields = append(fields, integerField(fmt.Sprintf("reward_%d_count", index), fmt.Sprintf("Reward %d Count", index+1), "row", 65535), integerField(fmt.Sprintf("reward_%d_item_code", index), fmt.Sprintf("Reward %d Item", index+1), "row", 65535)) + } + rows := make([]ZoneDataRow, len(parsed)) + for rowIndex, record := range parsed { + values := map[string]any{"present_set_code": record.PresentSetCode()} + for index := 0; index < presentitemsetfile.RewardCount; index++ { + count, itemCode, _ := record.Reward(index) + values[fmt.Sprintf("reward_%d_count", index)] = count + values[fmt.Sprintf("reward_%d_item_code", index)] = itemCode + } + rows[rowIndex] = row(rowIndex, record.Raw[:], values) + } + + return fields, rows, nil +} + +func decodePets(data []byte) ([]ZoneDataField, []ZoneDataRow, error) { + parsed, err := petfile.Read(bytes.NewReader(data)) + if err != nil { + return nil, nil, err + } + + fields := []ZoneDataField{ + integerField("group", "Group", "row", 65535), integerField("code", "Code", "row", petfile.CodeMax), stringField("name", "Name", "row"), + integerField("price", "Price", "row", 4294967295), integerField("auth", "Authorization", "row", 65535), integerField("limit", "Limit", "row", 65535), + integerField("data_1", "Data 1", "row", 65535), integerField("data_2", "Data 2", "row", 65535), + } + rows := make([]ZoneDataRow, len(parsed)) + for index, record := range parsed { + rows[index] = row(index, record.Raw[:], map[string]any{"group": record.Group(), "code": record.Code(), "name": record.Name(), "price": record.Price(), "auth": record.Auth(), "limit": record.Limit(), "data_1": record.Data1(), "data_2": record.Data2()}) + } + + return fields, rows, nil +} + +func decodeShueCombinations(data []byte) ([]ZoneDataField, []ZoneDataRow, error) { + parsed, err := shuecombinationfile.Read(bytes.NewReader(data)) + if err != nil { + return nil, nil, err + } + + fields := make([]ZoneDataField, shuecombinationfile.FieldCount) + for index, label := range shueCombinationLabels { + fields[index] = integerField(fmt.Sprintf("field_%d", index), label, "row", 65535) + } + rows := make([]ZoneDataRow, len(parsed)) + for rowIndex, record := range parsed { + values := make(map[string]any, shuecombinationfile.FieldCount) + for index := 0; index < shuecombinationfile.FieldCount; index++ { + values[fmt.Sprintf("field_%d", index)], _ = record.Field(index) + } + rows[rowIndex] = row(rowIndex, record.Raw[:], values) + } + + return fields, rows, nil +} + +func decodeLottery(data []byte) ([]ZoneDataField, []ZoneDataRow, error) { + parsed, err := lotteryfile.Read(bytes.NewReader(data)) + if err != nil { + return nil, nil, err + } + + fields := []ZoneDataField{integerField("event_item_id", "Event Item ID", "row", 255), integerField("enabled", "Enabled", "row", 255), integerField("reward_item_code", "Reward Item", "row", 65535)} + for index := 0; index < lotteryfile.MessageCount; index++ { + fields = append(fields, stringField(fmt.Sprintf("message_%d", index), fmt.Sprintf("Message %d", index+1), "row")) + } + rows := make([]ZoneDataRow, len(parsed)) + for rowIndex, record := range parsed { + values := map[string]any{"event_item_id": record.EventItemID(), "enabled": record.Enabled(), "reward_item_code": record.RewardItemCode()} + for index := 0; index < lotteryfile.MessageCount; index++ { + values[fmt.Sprintf("message_%d", index)], _ = record.Message(index) + } + rows[rowIndex] = row(rowIndex, record.Raw[:], values) + } + + return fields, rows, nil +} + +func decodeDerbyGifts(data []byte) ([]ZoneDataField, []ZoneDataRow, error) { + parsed, err := derbygiftfile.Read(bytes.NewReader(data)) + if err != nil { + return nil, nil, err + } + + fields := []ZoneDataField{integerField("item_code", "Item Code", "row", 4294967295), integerField("quantity", "Quantity", "row", 4294967295), integerField("weight", "Probability Weight", "row", derbygiftfile.MaxProbability)} + rows := make([]ZoneDataRow, len(parsed)) + for index, record := range parsed { + rows[index] = row(index, record.Raw[:], map[string]any{"item_code": record.ItemCode(), "quantity": record.Quantity(), "weight": record.Weight()}) + } + + return fields, rows, nil +} + +func decodeEventItemRewards(data []byte) ([]ZoneDataField, []ZoneDataRow, error) { + parsed, err := eventitemrewardfile.Read(bytes.NewReader(data)) + if err != nil { + return nil, nil, err + } + + fields := []ZoneDataField{integerField("item_code", "Item Code", "row", 65535), integerField("weight", "Probability Weight", "row", 65535), stringField("message", "Message", "row")} + rows := make([]ZoneDataRow, len(parsed)) + for index, record := range parsed { + rows[index] = row(index, record.Raw[:], map[string]any{"item_code": record.ItemCode(), "weight": record.Weight(), "message": record.Message()}) + } + + return fields, rows, nil +} + +func decodeA3Presents(data []byte) ([]ZoneDataField, []ZoneDataRow, error) { + parsed, err := a3presentfile.Read(bytes.NewReader(data)) + if err != nil { + return nil, nil, err + } + + fields := []ZoneDataField{stringField("name", "Character Name", "row")} + for index := 0; index < a3presentfile.RewardCount; index++ { + fields = append(fields, integerField(fmt.Sprintf("reward_%d_count", index), fmt.Sprintf("Reward %d Count", index+1), "row", 65535), integerField(fmt.Sprintf("reward_%d_item_code", index), fmt.Sprintf("Reward %d Item", index+1), "row", 65535)) + } + fields = append(fields, integerField("money", "Money", "row", 4294967295), integerField("lore", "Lore", "row", 4294967295), integerField("experience", "Experience", "row", 4294967295), integerField("offered", "Offered", "row", 65535)) + rows := make([]ZoneDataRow, len(parsed)) + for rowIndex, record := range parsed { + values := map[string]any{"name": record.Name(), "money": record.Money(), "lore": record.Lore(), "experience": record.Experience(), "offered": record.Offered()} + for index := 0; index < a3presentfile.RewardCount; index++ { + count, itemCode, _ := record.Reward(index) + values[fmt.Sprintf("reward_%d_count", index)] = count + values[fmt.Sprintf("reward_%d_item_code", index)] = itemCode + } + rows[rowIndex] = row(rowIndex, record.Raw[:], values) + } + + return fields, rows, nil +} diff --git a/internal/services/zone_data_remaining_apply.go b/internal/services/zone_data_remaining_apply.go new file mode 100644 index 0000000..2ba2041 --- /dev/null +++ b/internal/services/zone_data_remaining_apply.go @@ -0,0 +1,473 @@ +package services + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + + "github.com/project-agonyl/agonyl-utils-go/messagefile" + "github.com/project-agonyl/agonyl-utils-go/oxquizfile" + "github.com/project-agonyl/agonyl-utils-go/questexfile" + "github.com/project-agonyl/agonyl-utils-go/squestquizfile" + "github.com/project-agonyl/agonyl-utils-go/towertreasurefile" + "github.com/project-agonyl/agonyl-utils-go/tyrbasefile" + "github.com/project-agonyl/agonyl-utils-go/tyrgiftfile" + "github.com/project-agonyl/agonyl-utils-go/tyrnpcregenfile" + "github.com/project-agonyl/agonyl-utils-go/tyrportalfile" + "github.com/project-agonyl/agonyl-utils-go/tyrskilllayerfile" + "github.com/project-agonyl/agonyl-utils-go/tyrstartpointfile" + "github.com/project-agonyl/agonyl-utils-go/tyrupgradefile" +) + +func applyRemainingZoneData(original []byte, format ZoneDataFormat, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + switch format { + case ZoneDataFormatMessage: + return applyMessages(original, operations, allowed) + case ZoneDataFormatQuestEx: + return applyQuestEx(original, operations, allowed) + case ZoneDataFormatSQuestQuiz: + return applySQuestQuiz(original, operations, allowed) + case ZoneDataFormatTowerTreasure: + return applyTowerTreasure(original, operations, allowed) + case ZoneDataFormatOXQuiz: + return applyOXQuiz(original, operations, allowed) + case ZoneDataFormatTyrBase: + return applyTyrUint16(original, operations, allowed, &tyrBaseCodec{}) + case ZoneDataFormatTyrPortal: + return applyTyrUint16(original, operations, allowed, &tyrPortalCodec{}) + case ZoneDataFormatTyrUpgrade: + return applyTyrUint16(original, operations, allowed, &tyrUpgradeCodec{}) + case ZoneDataFormatTyrStartPoint: + return applyTyrUint16(original, operations, allowed, &tyrStartCodec{}) + case ZoneDataFormatTyrGift: + return applyTyrGift(original, operations, allowed) + case ZoneDataFormatTyrNPCRegen: + return applyTyrNPCRegen(original, operations, allowed) + case ZoneDataFormatTyrSkillLayer: + return applyTyrSkillLayer(original, operations, allowed) + default: + return nil, fmt.Errorf("unsupported remaining ZoneData format %q", format) + } +} + +func applyMessages(original []byte, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + data, err := messagefile.Read(bytes.NewReader(original)) + if err != nil { + return nil, err + } + offsets := make([]int, len(data)) + offset := 2 + for i, r := range data { + offsets[i] = offset + offset += len(r.Raw) + } + for _, op := range operations { + if err := validateRowOperation(op, len(data)); err != nil { + return nil, err + } + switch op.Field { + case "index": + v, err := operationUint(op, messagefile.MaxRecords-1) + if err != nil { + return nil, err + } + data[op.Row].SetIndex(uint16(v)) + allowBytes(allowed, offsets[op.Row], 2) + case "text": + v, err := operationString(op) + if err != nil { + return nil, err + } + if len(v) != len(data[op.Row].Text()) { + return nil, fmt.Errorf("message text length must remain %d bytes", len(data[op.Row].Text())) + } + if err := data[op.Row].SetText(v); err != nil { + return nil, err + } + allowBytes(allowed, offsets[op.Row]+6, len(v)) + default: + return nil, fmt.Errorf("unknown message field %q", op.Field) + } + } + return writeZoneData(func(w io.Writer) error { return messagefile.Write(w, data) }) +} + +func applyQuestEx(original []byte, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + data, err := questexfile.Read(bytes.NewReader(original)) + if err != nil { + return nil, err + } + for _, op := range operations { + if op.Scope != "row" || op.Row != 0 { + return nil, fmt.Errorf("invalid QuestEx target") + } + found := false + for _, field := range questExFields { + if field.key != op.Field { + continue + } + value, err := operationInt32(op) + if err != nil { + return nil, err + } + if err := data.SetInt32(field.offset, value); err != nil { + return nil, err + } + physical := field.offset + if field.offset >= questexfile.NextQuestOffset { + physical = len(original) - (questexfile.NextQuestOffset + questexfile.NextQuestCount*4 - field.offset) + } + allowBytes(allowed, physical, 4) + found = true + break + } + if !found { + return nil, fmt.Errorf("unknown QuestEx field %q", op.Field) + } + } + return writeZoneData(func(w io.Writer) error { return questexfile.Write(w, data) }) +} + +func applySQuestQuiz(original []byte, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + data, err := squestquizfile.Read(bytes.NewReader(original)) + if err != nil { + return nil, err + } + for _, op := range operations { + if err := validateRowOperation(op, len(data)); err != nil { + return nil, err + } + offset := op.Row * squestquizfile.RecordSize + switch op.Field { + case "id": + v, err := operationUint(op, 65535) + if err != nil { + return nil, err + } + data[op.Row].SetID(uint16(v)) + allowBytes(allowed, offset, 2) + case "question": + v, err := operationString(op) + if err != nil { + return nil, err + } + if err := data[op.Row].SetQuestion(v); err != nil { + return nil, err + } + allowBytes(allowed, offset+2, squestquizfile.QuestionSize) + case "correct": + v, err := operationUint(op, squestquizfile.AnswerCount-1) + if err != nil { + return nil, err + } + if err := data[op.Row].SetCorrect(uint16(v)); err != nil { + return nil, err + } + allowBytes(allowed, offset+0x260, 2) + default: + index, ok := fieldIndex(op.Field, "answer_", squestquizfile.AnswerCount) + if !ok { + return nil, fmt.Errorf("unknown SQuest quiz field %q", op.Field) + } + v, err := operationString(op) + if err != nil { + return nil, err + } + if err := data[op.Row].SetAnswer(index, v); err != nil { + return nil, err + } + allowBytes(allowed, offset+0xcb+index*squestquizfile.AnswerSize, squestquizfile.AnswerSize) + } + } + return writeZoneData(func(w io.Writer) error { return squestquizfile.Write(w, data) }) +} + +func applyTowerTreasure(original []byte, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + data, err := towertreasurefile.Read(bytes.NewReader(original)) + if err != nil { + return nil, err + } + for _, op := range operations { + if err := validateRowOperation(op, len(data.Records)); err != nil { + return nil, err + } + v, err := operationUint(op, 4294967295) + if err != nil { + return nil, err + } + offset := op.Row * towertreasurefile.RecordSize + switch op.Field { + case "item_code": + if v > 65535 { + return nil, fmt.Errorf("item code exceeds 65535") + } + data.Records[op.Row].SetItemCode(uint16(v)) + allowBytes(allowed, offset, 2) + case "weight": + data.Records[op.Row].SetWeight(uint32(v)) + allowBytes(allowed, offset+2, 4) + default: + return nil, fmt.Errorf("unknown Tower treasure field %q", op.Field) + } + } + return writeZoneData(func(w io.Writer) error { return towertreasurefile.Write(w, data) }) +} + +func applyOXQuiz(original []byte, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + data, err := oxquizfile.Read(bytes.NewReader(original)) + if err != nil { + return nil, err + } + for _, op := range operations { + if err := validateRowOperation(op, len(data.Records)); err != nil { + return nil, err + } + offset := op.Row * oxquizfile.RecordSize + switch op.Field { + case "reward_code", "reward_count": + v, err := operationUint(op, 65535) + if err != nil { + return nil, err + } + if op.Field == "reward_code" { + data.Records[op.Row].SetRewardCode(uint16(v)) + allowBytes(allowed, offset, 2) + } else { + data.Records[op.Row].SetRewardCount(uint16(v)) + allowBytes(allowed, offset+2, 2) + } + case "answer": + v, err := operationString(op) + if err != nil || len(v) != 1 { + return nil, fmt.Errorf("answer must be O or X") + } + if err := data.Records[op.Row].SetAnswer(v[0]); err != nil { + return nil, err + } + allowBytes(allowed, offset+4, 1) + case "question", "explanation": + v, err := operationString(op) + if err != nil { + return nil, err + } + if op.Field == "question" { + if err := data.Records[op.Row].SetQuestion(v); err != nil { + return nil, err + } + allowBytes(allowed, offset+5, oxquizfile.TextSize) + } else { + if err := data.Records[op.Row].SetExplanation(v); err != nil { + return nil, err + } + allowBytes(allowed, offset+0x45, oxquizfile.TextSize) + } + default: + return nil, fmt.Errorf("unknown OXQuiz field %q", op.Field) + } + } + return writeZoneData(func(w io.Writer) error { return oxquizfile.Write(w, data) }) +} + +type tyrUint16Codec interface { + read([]byte) (int, int, error) + set(int, int, uint16) error + write(io.Writer) error +} + +func applyTyrUint16(original []byte, operations []ZoneDataOperation, allowed []bool, codec tyrUint16Codec) ([]byte, error) { + rows, size, err := codec.read(original) + if err != nil { + return nil, err + } + for _, op := range operations { + if err := validateRowOperation(op, rows); err != nil { + return nil, err + } + index, ok := fieldIndex(op.Field, "field_", size/2) + if !ok { + return nil, fmt.Errorf("unknown Tyr field %q", op.Field) + } + v, err := operationUint(op, 65535) + if err != nil { + return nil, err + } + if err := codec.set(op.Row, index, uint16(v)); err != nil { + return nil, err + } + allowBytes(allowed, op.Row*size+index*2, 2) + } + return writeZoneData(codec.write) +} + +type tyrBaseCodec struct{ data tyrbasefile.Data } + +func (c *tyrBaseCodec) read(b []byte) (int, int, error) { + d, e := tyrbasefile.Read(bytes.NewReader(b)) + c.data = d + return len(d), tyrbasefile.RecordSize, e +} +func (c *tyrBaseCodec) set(r, f int, v uint16) error { return c.data[r].SetField(f, v) } +func (c *tyrBaseCodec) write(w io.Writer) error { return tyrbasefile.Write(w, c.data) } + +type tyrPortalCodec struct{ data tyrportalfile.Data } + +func (c *tyrPortalCodec) read(b []byte) (int, int, error) { + d, e := tyrportalfile.Read(bytes.NewReader(b)) + c.data = d + return len(d), tyrportalfile.RecordSize, e +} +func (c *tyrPortalCodec) set(r, f int, v uint16) error { return c.data[r].SetField(f, v) } +func (c *tyrPortalCodec) write(w io.Writer) error { return tyrportalfile.Write(w, c.data) } + +type tyrUpgradeCodec struct{ data tyrupgradefile.Data } + +func (c *tyrUpgradeCodec) read(b []byte) (int, int, error) { + d, e := tyrupgradefile.Read(bytes.NewReader(b)) + c.data = d + return len(d), tyrupgradefile.RecordSize, e +} +func (c *tyrUpgradeCodec) set(r, f int, v uint16) error { return c.data[r].SetField(f, v) } +func (c *tyrUpgradeCodec) write(w io.Writer) error { return tyrupgradefile.Write(w, c.data) } + +type tyrStartCodec struct{ data tyrstartpointfile.Data } + +func (c *tyrStartCodec) read(b []byte) (int, int, error) { + d, e := tyrstartpointfile.Read(bytes.NewReader(b)) + c.data = d + return len(d), tyrstartpointfile.RecordSize, e +} +func (c *tyrStartCodec) set(r, f int, v uint16) error { return c.data[r].SetField(f, v) } +func (c *tyrStartCodec) write(w io.Writer) error { return tyrstartpointfile.Write(w, c.data) } + +func applyTyrGift(original []byte, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + data, err := tyrgiftfile.Read(bytes.NewReader(original)) + if err != nil { + return nil, err + } + for _, op := range operations { + if err := validateRowOperation(op, len(data)); err != nil { + return nil, err + } + v, err := operationUint(op, 4294967295) + if err != nil { + return nil, err + } + offset := op.Row * tyrgiftfile.RecordSize + switch op.Field { + case "item_code": + data[op.Row].SetItemCode(uint32(v)) + allowBytes(allowed, offset, 4) + case "count": + data[op.Row].SetCount(uint32(v)) + allowBytes(allowed, offset+4, 4) + case "weight": + if v > tyrgiftfile.MaxWeight { + return nil, fmt.Errorf("weight exceeds %d", tyrgiftfile.MaxWeight) + } + data[op.Row].SetWeight(uint32(v)) + allowBytes(allowed, offset+8, 4) + default: + return nil, fmt.Errorf("unknown Tyr gift field %q", op.Field) + } + } + return writeZoneData(func(w io.Writer) error { return tyrgiftfile.Write(w, data) }) +} + +func applyTyrNPCRegen(original []byte, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + data, err := tyrnpcregenfile.Read(bytes.NewReader(original)) + if err != nil { + return nil, err + } + fields := map[string]int{"base_index": 0, "npc_type": 1, "x": 2, "y": 3, "direction": 4} + for _, op := range operations { + if err := validateRowOperation(op, len(data.Records)); err != nil { + return nil, err + } + offset := op.Row * data.RecordSize + if op.Field == "step" { + if data.RecordSize != tyrnpcregenfile.FullRecordSize { + return nil, fmt.Errorf("compact NPC regen has no step") + } + v, err := operationUint(op, 255) + if err != nil { + return nil, err + } + data.Records[op.Row].SetStep(byte(v)) + allowBytes(allowed, offset+10, 1) + continue + } + index, ok := fields[op.Field] + if !ok { + return nil, fmt.Errorf("unknown Tyr NPC regen field %q", op.Field) + } + v, err := operationUint(op, 65535) + if err != nil { + return nil, err + } + if err := data.Records[op.Row].SetField(index, uint16(v)); err != nil { + return nil, err + } + allowBytes(allowed, offset+index*2, 2) + } + return writeZoneData(func(w io.Writer) error { return tyrnpcregenfile.Write(w, data) }) +} + +func applyTyrSkillLayer(original []byte, operations []ZoneDataOperation, allowed []bool) ([]byte, error) { + data, err := tyrskilllayerfile.Read(bytes.NewReader(original)) + if err != nil { + return nil, err + } + for _, op := range operations { + if err := validateRowOperation(op, len(data)); err != nil { + return nil, err + } + offset := op.Row * tyrskilllayerfile.RecordSize + switch op.Field { + case "class_index", "skill_index": + v, err := operationUint(op, 255) + if err != nil { + return nil, err + } + if op.Field == "class_index" { + data[op.Row].SetClassIndex(uint16(v)) + allowBytes(allowed, offset, 2) + } else { + data[op.Row].SetSkillIndex(uint16(v)) + allowBytes(allowed, offset+2, 2) + } + default: + var prefix string + var base int + if strings.HasPrefix(op.Field, "player_to_target_") { + prefix = "player_to_target_" + base = 4 + } else if strings.HasPrefix(op.Field, "target_to_effect_") { + prefix = "target_to_effect_" + base = 0x0e + } else { + return nil, fmt.Errorf("unknown Tyr skill layer field %q", op.Field) + } + index, err := strconv.Atoi(strings.TrimPrefix(op.Field, prefix)) + if err != nil || index < 0 || index >= tyrskilllayerfile.FlagCount { + return nil, fmt.Errorf("unknown Tyr skill layer field %q", op.Field) + } + var v bool + if err := json.Unmarshal(op.Value, &v); err != nil { + return nil, fmt.Errorf("%s must be boolean", op.Field) + } + if prefix == "player_to_target_" { + err = data[op.Row].SetPlayerToTarget(index, v) + } else { + err = data[op.Row].SetTargetToEffect(index, v) + } + if err != nil { + return nil, err + } + allowBytes(allowed, offset+base+index*2, 2) + } + } + return writeZoneData(func(w io.Writer) error { return tyrskilllayerfile.Write(w, data) }) +} diff --git a/internal/services/zone_data_remaining_decode.go b/internal/services/zone_data_remaining_decode.go new file mode 100644 index 0000000..bb67510 --- /dev/null +++ b/internal/services/zone_data_remaining_decode.go @@ -0,0 +1,231 @@ +package services + +import ( + "bytes" + "fmt" + + "github.com/project-agonyl/agonyl-utils-go/messagefile" + "github.com/project-agonyl/agonyl-utils-go/oxquizfile" + "github.com/project-agonyl/agonyl-utils-go/questexfile" + "github.com/project-agonyl/agonyl-utils-go/squestquizfile" + "github.com/project-agonyl/agonyl-utils-go/towertreasurefile" + "github.com/project-agonyl/agonyl-utils-go/tyrbasefile" + "github.com/project-agonyl/agonyl-utils-go/tyrgiftfile" + "github.com/project-agonyl/agonyl-utils-go/tyrnpcregenfile" + "github.com/project-agonyl/agonyl-utils-go/tyrportalfile" + "github.com/project-agonyl/agonyl-utils-go/tyrskilllayerfile" + "github.com/project-agonyl/agonyl-utils-go/tyrstartpointfile" + "github.com/project-agonyl/agonyl-utils-go/tyrupgradefile" +) + +var questExFields = []struct { + key, label string + offset int +}{ + {"index", "Quest Index", 0}, {"start_npc", "Start NPC", 4}, {"end_npc", "End NPC", 8}, {"is_head", "Is Head", 12}, + {"required_class", "Required Class", 0x10}, {"required_item", "Required Item", 0x18}, {"previous_quest", "Previous Quest", 0x1c}, + {"minimum_level", "Minimum Level", 0x20}, {"maximum_level", "Maximum Level", 0x24}, {"required_favor", "Required Favor", 0x28}, + {"reward_experience", "Reward Experience", 0x50}, {"reward_money", "Reward Money", 0x54}, {"reward_lore", "Reward Lore", 0x58}, {"reward_favor", "Reward Favor", 0x5c}, + {"next_quest_0", "Next Quest 1", 0x664}, {"next_quest_1", "Next Quest 2", 0x668}, {"next_quest_2", "Next Quest 3", 0x66c}, +} + +func decodeRemainingZoneData(data []byte, format ZoneDataFormat) ([]ZoneDataField, []ZoneDataRow, error) { + switch format { + case ZoneDataFormatMessage: + parsed, err := messagefile.Read(bytes.NewReader(data)) + if err != nil { + return nil, nil, err + } + fields := []ZoneDataField{integerField("index", "Index", "row", messagefile.MaxRecords-1), stringField("text", "Text", "row")} + rows := make([]ZoneDataRow, len(parsed)) + for i, r := range parsed { + rows[i] = row(i, r.Raw, map[string]any{"index": r.Index(), "text": r.Text()}) + } + return fields, rows, nil + case ZoneDataFormatQuestEx: + parsed, err := questexfile.Read(bytes.NewReader(data)) + if err != nil { + return nil, nil, err + } + fields := make([]ZoneDataField, len(questExFields)) + values := make(map[string]any, len(fields)) + for i, f := range questExFields { + fields[i] = signedField(f.key, f.label, "row") + values[f.key] = parsed.Int32(f.offset) + } + return fields, []ZoneDataRow{row(0, parsed.Raw[:], values)}, nil + case ZoneDataFormatSQuestQuiz: + parsed, err := squestquizfile.Read(bytes.NewReader(data)) + if err != nil { + return nil, nil, err + } + fields := []ZoneDataField{integerField("id", "ID", "row", 65535), stringField("question", "Question", "row")} + for i := 0; i < squestquizfile.AnswerCount; i++ { + fields = append(fields, stringField(fmt.Sprintf("answer_%d", i), fmt.Sprintf("Answer %d", i+1), "row")) + } + fields = append(fields, integerField("correct", "Correct Answer", "row", squestquizfile.AnswerCount-1)) + rows := make([]ZoneDataRow, len(parsed)) + for i, r := range parsed { + v := map[string]any{"id": r.ID(), "question": r.Question(), "correct": r.Correct()} + for a := 0; a < squestquizfile.AnswerCount; a++ { + v[fmt.Sprintf("answer_%d", a)], _ = r.Answer(a) + } + rows[i] = row(i, r.Raw[:], v) + } + return fields, rows, nil + case ZoneDataFormatTowerTreasure: + parsed, err := towertreasurefile.Read(bytes.NewReader(data)) + if err != nil { + return nil, nil, err + } + fields := []ZoneDataField{integerField("item_code", "Item Code", "row", 65535), integerField("weight", "Weight", "row", 4294967295)} + rows := make([]ZoneDataRow, len(parsed.Records)) + for i, r := range parsed.Records { + rows[i] = row(i, r.Raw[:], map[string]any{"item_code": r.ItemCode(), "weight": r.Weight()}) + } + return fields, rows, nil + case ZoneDataFormatOXQuiz: + parsed, err := oxquizfile.Read(bytes.NewReader(data)) + if err != nil { + return nil, nil, err + } + fields := []ZoneDataField{integerField("reward_code", "Reward Code", "row", 65535), integerField("reward_count", "Reward Count", "row", 65535), stringField("answer", "Answer", "row"), stringField("question", "Question", "row"), stringField("explanation", "Explanation", "row")} + rows := make([]ZoneDataRow, len(parsed.Records)) + for i, r := range parsed.Records { + rows[i] = row(i, r.Raw[:], map[string]any{"reward_code": r.RewardCode(), "reward_count": r.RewardCount(), "answer": string(r.Answer()), "question": r.Question(), "explanation": r.Explanation()}) + } + return fields, rows, nil + case ZoneDataFormatTyrBase: + parsed, err := tyrbasefile.Read(bytes.NewReader(data)) + if err != nil { + return nil, nil, err + } + raw, values := parsedToRawBase(parsed) + return buildUint16Rows(raw, values, []string{"Index", "Grade", "War Point Value", "Morale Value", "Nation"}) + case ZoneDataFormatTyrPortal: + parsed, err := tyrportalfile.Read(bytes.NewReader(data)) + if err != nil { + return nil, nil, err + } + raw := make([][]byte, len(parsed)) + values := make([][]uint16, len(parsed)) + for i, r := range parsed { + raw[i] = r.Raw[:] + values[i] = []uint16{r.SourceX(), r.SourceY(), r.DestinationX(), r.DestinationY()} + } + return buildUint16Rows(raw, values, []string{"Source X", "Source Y", "Destination X", "Destination Y"}) + case ZoneDataFormatTyrUpgrade: + parsed, err := tyrupgradefile.Read(bytes.NewReader(data)) + if err != nil { + return nil, nil, err + } + raw := make([][]byte, len(parsed)) + values := make([][]uint16, len(parsed)) + labels := make([]string, tyrupgradefile.FieldCount) + for i := range labels { + labels[i] = fmt.Sprintf("Field %d", i) + } + for i, r := range parsed { + raw[i] = r.Raw[:] + values[i] = make([]uint16, tyrupgradefile.FieldCount) + for f := range values[i] { + values[i][f] = r.Field(f) + } + } + return buildUint16Rows(raw, values, labels) + case ZoneDataFormatTyrStartPoint: + parsed, err := tyrstartpointfile.Read(bytes.NewReader(data)) + if err != nil { + return nil, nil, err + } + raw := make([][]byte, len(parsed)) + values := make([][]uint16, len(parsed)) + for i, r := range parsed { + raw[i] = r.Raw[:] + values[i] = make([]uint16, tyrstartpointfile.FieldCount) + for f := range values[i] { + values[i][f] = r.Field(f) + } + } + return buildUint16Rows(raw, values, []string{"Rank", "Unit", "Nation", "X", "Y", "Direction"}) + case ZoneDataFormatTyrGift: + parsed, err := tyrgiftfile.Read(bytes.NewReader(data)) + if err != nil { + return nil, nil, err + } + fields := []ZoneDataField{integerField("item_code", "Item Code", "row", 4294967295), integerField("count", "Count", "row", 4294967295), integerField("weight", "Weight", "row", tyrgiftfile.MaxWeight)} + rows := make([]ZoneDataRow, len(parsed)) + for i, r := range parsed { + rows[i] = row(i, r.Raw[:], map[string]any{"item_code": r.ItemCode(), "count": r.Count(), "weight": r.Weight()}) + } + return fields, rows, nil + case ZoneDataFormatTyrNPCRegen: + parsed, err := tyrnpcregenfile.Read(bytes.NewReader(data)) + if err != nil { + return nil, nil, err + } + fields := []ZoneDataField{integerField("base_index", "Base Index", "row", 15), integerField("npc_type", "NPC Type", "row", 65535), integerField("x", "X", "row", 65535), integerField("y", "Y", "row", 65535), integerField("direction", "Direction", "row", 8)} + if parsed.RecordSize == tyrnpcregenfile.FullRecordSize { + fields = append(fields, integerField("step", "Step", "row", 255)) + } + rows := make([]ZoneDataRow, len(parsed.Records)) + for i, r := range parsed.Records { + v := map[string]any{"base_index": r.BaseIndex(), "npc_type": r.NPCType(), "x": r.X(), "y": r.Y(), "direction": r.Direction()} + if parsed.RecordSize == tyrnpcregenfile.FullRecordSize { + v["step"] = r.Step() + } + rows[i] = row(i, r.Raw[:parsed.RecordSize], v) + } + return fields, rows, nil + case ZoneDataFormatTyrSkillLayer: + parsed, err := tyrskilllayerfile.Read(bytes.NewReader(data)) + if err != nil { + return nil, nil, err + } + fields := []ZoneDataField{integerField("class_index", "Class", "row", 255), integerField("skill_index", "Skill", "row", 255)} + for i := -2; i <= 2; i++ { + fields = append(fields, boolField(fmt.Sprintf("player_to_target_%d", i+2), fmt.Sprintf("Player to Target Height %+d", i), "row"), boolField(fmt.Sprintf("target_to_effect_%d", i+2), fmt.Sprintf("Target to Effect Height %+d", i), "row")) + } + rows := make([]ZoneDataRow, len(parsed)) + for i, r := range parsed { + v := map[string]any{"class_index": r.ClassIndex(), "skill_index": r.SkillIndex()} + for f := 0; f < tyrskilllayerfile.FlagCount; f++ { + v[fmt.Sprintf("player_to_target_%d", f)], _ = r.PlayerToTarget(f) + v[fmt.Sprintf("target_to_effect_%d", f)], _ = r.TargetToEffect(f) + } + rows[i] = row(i, r.Raw[:], v) + } + return fields, rows, nil + default: + return nil, nil, fmt.Errorf("unsupported remaining ZoneData format %q", format) + } +} + +func signedField(key, label, scope string) ZoneDataField { + min, max := int64(-2147483648), int64(2147483647) + return ZoneDataField{Key: key, Label: label, Type: "integer", Scope: scope, Editable: true, Min: &min, Max: &max} +} +func parsedToRawBase(parsed tyrbasefile.Data) ([][]byte, [][]uint16) { + raw := make([][]byte, len(parsed)) + values := make([][]uint16, len(parsed)) + for i, r := range parsed { + raw[i] = r.Raw[:] + values[i] = []uint16{r.Index(), r.Grade(), r.WarPointValue(), r.MoraleValue(), r.Nation()} + } + return raw, values +} +func buildUint16Rows(raw [][]byte, values [][]uint16, labels []string) ([]ZoneDataField, []ZoneDataRow, error) { + fields := make([]ZoneDataField, len(labels)) + for i, label := range labels { + fields[i] = integerField(fmt.Sprintf("field_%d", i), label, "row", 65535) + } + rows := make([]ZoneDataRow, len(raw)) + for i := range raw { + v := make(map[string]any, len(labels)) + for f := range labels { + v[fmt.Sprintf("field_%d", f)] = values[i][f] + } + rows[i] = row(i, raw[i], v) + } + return fields, rows, nil +} diff --git a/internal/services/zone_data_service.go b/internal/services/zone_data_service.go new file mode 100644 index 0000000..0fd3247 --- /dev/null +++ b/internal/services/zone_data_service.go @@ -0,0 +1,466 @@ +package services + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "path/filepath" + "strconv" + "strings" + + "github.com/omnihance/omnihance-a3-agent/internal/constants" + "github.com/omnihance/omnihance-a3-agent/internal/db" + "github.com/omnihance/omnihance-a3-agent/internal/utils" +) + +type ZoneDataFormat string + +const ( + ZoneDataFormatMap ZoneDataFormat = "zone_map" + ZoneDataFormatNPCSkill ZoneDataFormat = "npc_skill" + ZoneDataFormatNPCFavor ZoneDataFormat = "npc_favor" + ZoneDataFormatPCData ZoneDataFormat = "pc_data" + ZoneDataFormatSkillData ZoneDataFormat = "skill_data" + ZoneDataFormatSkillDelay ZoneDataFormat = "skill_delay" + ZoneDataFormatPassiveSkill ZoneDataFormat = "passive_skill" + ZoneDataFormatHiredSoldierSkill ZoneDataFormat = "hired_soldier_skill" + ZoneDataFormatCashItem ZoneDataFormat = "cash_item" + ZoneDataFormatSetItem ZoneDataFormat = "set_item" + ZoneDataFormatPresentItemSet ZoneDataFormat = "present_item_set" + ZoneDataFormatPet ZoneDataFormat = "pet" + ZoneDataFormatShueCombination ZoneDataFormat = "shue_combination" + ZoneDataFormatLottery ZoneDataFormat = "lottery" + ZoneDataFormatDerbyGift ZoneDataFormat = "derby_gift" + ZoneDataFormatEventItemReward ZoneDataFormat = "event_item_reward" + ZoneDataFormatA3Present ZoneDataFormat = "a3_present" + ZoneDataFormatMessage ZoneDataFormat = "message" + ZoneDataFormatQuestEx ZoneDataFormat = "quest_ex" + ZoneDataFormatSQuestQuiz ZoneDataFormat = "squest_quiz" + ZoneDataFormatTowerTreasure ZoneDataFormat = "tower_treasure" + ZoneDataFormatOXQuiz ZoneDataFormat = "ox_quiz" + ZoneDataFormatTyrBase ZoneDataFormat = "tyr_base" + ZoneDataFormatTyrPortal ZoneDataFormat = "tyr_portal" + ZoneDataFormatTyrUpgrade ZoneDataFormat = "tyr_upgrade" + ZoneDataFormatTyrStartPoint ZoneDataFormat = "tyr_start_point" + ZoneDataFormatTyrGift ZoneDataFormat = "tyr_gift" + ZoneDataFormatTyrNPCRegen ZoneDataFormat = "tyr_npc_regen" + ZoneDataFormatTyrSkillLayer ZoneDataFormat = "tyr_skill_layer" +) + +var ErrZoneDataStale = errors.New("zone data source hash is stale") + +type ZoneDataField struct { + Key string `json:"key"` + Label string `json:"label"` + Type string `json:"type"` + Scope string `json:"scope"` + Editable bool `json:"editable"` + Min *int64 `json:"min,omitempty"` + Max *int64 `json:"max,omitempty"` +} + +type ZoneDataRow struct { + Index int `json:"index"` + Values map[string]any `json:"values"` + OpaqueBytes string `json:"opaque_bytes"` +} + +type ZoneDataMap struct { + Name string `json:"name"` + Warps []ZoneDataRow `json:"warps"` + Cells []uint32 `json:"cells"` + Width int `json:"width"` + Height int `json:"height"` + Trailing string `json:"trailing_opaque_bytes"` +} + +type ZoneDataCapabilities struct { + UpdateFields bool `json:"update_fields"` + InsertRows bool `json:"insert_rows"` + DeleteRows bool `json:"delete_rows"` +} + +type ZoneDataFile struct { + Format ZoneDataFormat `json:"format"` + SourceHash string `json:"source_hash"` + Schema []ZoneDataField `json:"schema"` + Rows []ZoneDataRow `json:"rows,omitempty"` + Map *ZoneDataMap `json:"map,omitempty"` + Capabilities ZoneDataCapabilities `json:"capabilities"` +} + +type ZoneDataOperation struct { + Scope string `json:"scope"` + Row int `json:"row"` + Field string `json:"field"` + Value json.RawMessage `json:"value"` +} + +type ZoneDataService interface { + ResolveRoot() (string, error) + Detect(root string, path string) (ZoneDataFormat, bool) + DetectResolved(root string, path string) (ZoneDataFormat, bool) + Read(path string, format ZoneDataFormat) (ZoneDataFile, error) + Apply(original []byte, format ZoneDataFormat, operations []ZoneDataOperation) ([]byte, error) +} + +type zoneDataService struct { + internalDB db.InternalDB + fileEditor FileEditorService +} + +func NewZoneDataService(internalDB db.InternalDB, fileEditor FileEditorService) ZoneDataService { + return &zoneDataService{internalDB: internalDB, fileEditor: fileEditor} +} + +func (s *zoneDataService) ResolveRoot() (string, error) { + setting, err := s.internalDB.GetSetting(constants.SettingKeyZoneServerPath) + if err != nil { + return "", err + } + + root := strings.TrimSpace(setting.Value) + if root == "" { + return "", fmt.Errorf("zone server path is not configured") + } + + rootPath, err := filepath.Abs(root) + if err != nil { + return "", err + } + + return filepath.EvalSymlinks(filepath.Clean(rootPath)) +} + +func (s *zoneDataService) Detect(root string, path string) (ZoneDataFormat, bool) { + rootPath, err := filepath.EvalSymlinks(filepath.Clean(root)) + if err != nil { + return "", false + } + + return s.DetectResolved(rootPath, path) +} + +func (s *zoneDataService) DetectResolved(root string, path string) (ZoneDataFormat, bool) { + if !IsZoneDataCandidatePath(path) { + return "", false + } + + rootPath := filepath.Clean(root) + filePath, err := filepath.EvalSymlinks(filepath.Clean(path)) + if err != nil { + return "", false + } + + relative, err := filepath.Rel(rootPath, filePath) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", false + } + + parts := strings.Split(strings.ToLower(filepath.ToSlash(relative)), "/") + if len(parts) == 1 && parts[0] == "a3msg_zone_tw.dat" { + return ZoneDataFormatMessage, true + } + if len(parts) == 1 && (numberedDataFile(parts[0], "present_") || numberedDataFile(parts[0], "reload_present_")) { + return ZoneDataFormatA3Present, true + } + + if len(parts) == 2 && parts[0] == "event" { + if parts[1] == "lotteryitem.dat" { + return ZoneDataFormatLottery, true + } + + if strings.HasPrefix(parts[1], "eventitem") && strings.HasSuffix(parts[1], ".dat") { + return ZoneDataFormatEventItemReward, true + } + } + if len(parts) == 2 && parts[0] == "tower" && numberedExtensionFile(parts[1], ".itm", 6) { + return ZoneDataFormatTowerTreasure, true + } + if len(parts) == 2 && parts[0] == "oxquiz" && parts[1] == "oxquiztable.dat" { + return ZoneDataFormatOXQuiz, true + } + if len(parts) == 3 && parts[0] == "zonedata" && parts[1] == "quest" && fixedDigitDataFile(parts[2], 4) { + return ZoneDataFormatQuestEx, true + } + if len(parts) == 3 && parts[0] == "zonedata" && parts[1] == "squest" && parts[2] == "quiztable.dat" { + return ZoneDataFormatSQuestQuiz, true + } + if len(parts) == 3 && parts[0] == "zonedata" && parts[1] == "tyr" { + switch parts[2] { + case "baseinfo.tyr": + return ZoneDataFormatTyrBase, true + case "warpportal.tyr": + return ZoneDataFormatTyrPortal, true + case "upgrade.tyr": + return ZoneDataFormatTyrUpgrade, true + case "startpoint.tyr": + return ZoneDataFormatTyrStartPoint, true + case "tyrgift.dat": + return ZoneDataFormatTyrGift, true + case "npcregen.tyr": + return ZoneDataFormatTyrNPCRegen, true + case "skilllayer.tyr": + return ZoneDataFormatTyrSkillLayer, true + } + } + + if len(parts) == 3 && parts[0] == "zonedata" { + switch parts[1] { + case "map": + if strings.HasSuffix(parts[2], ".map") { + return ZoneDataFormatMap, true + } + case "npc": + switch parts[2] { + case "npcskill": + return ZoneDataFormatNPCSkill, true + case "favindex.dat": + return ZoneDataFormatNPCFavor, true + case "derbygift.dat": + return ZoneDataFormatDerbyGift, true + } + case "shop": + if parts[2] == "cashitemtbl.dat" { + return ZoneDataFormatCashItem, true + } + case "item": + switch parts[2] { + case "sit0", "sit1", "sit2", "sit3": + return ZoneDataFormatSetItem, true + case "presentitemset.dat": + return ZoneDataFormatPresentItemSet, true + case "pet": + return ZoneDataFormatPet, true + case "shuecombinationdata": + return ZoneDataFormatShueCombination, true + } + case "pc": + if isClassFile(parts[2]) { + return ZoneDataFormatPCData, true + } + case "skill": + if isClassFile(parts[2]) { + return ZoneDataFormatSkillData, true + } + switch parts[2] { + case "skilldelay.dat": + return ZoneDataFormatSkillDelay, true + case "psvskill.dat": + return ZoneDataFormatPassiveSkill, true + case "hsst0", "hsst1", "hsst2", "hsst3": + return ZoneDataFormatHiredSoldierSkill, true + } + } + } + + return "", false +} + +func IsZoneDataCandidatePath(path string) bool { + extension := strings.ToLower(filepath.Ext(path)) + switch extension { + case ".dat", ".itm", ".map", ".tyr": + return true + case "": + name := strings.ToLower(filepath.Base(path)) + switch name { + case "0", "1", "2", "3", "npcskill", "pet", "shuecombinationdata", + "sit0", "sit1", "sit2", "sit3", "hsst0", "hsst1", "hsst2", "hsst3": + return true + } + } + + return false +} + +func (s *zoneDataService) Read(path string, format ZoneDataFormat) (ZoneDataFile, error) { + data, err := s.fileEditor.ReadFile(path) + if err != nil { + return ZoneDataFile{}, err + } + + decoded, err := decodeZoneData(data, format) + if err != nil { + return ZoneDataFile{}, err + } + + decoded.SourceHash = utils.CalculateFileHash(data) + return decoded, nil +} + +func isClassFile(value string) bool { + return value == "0" || value == "1" || value == "2" || value == "3" +} + +func numberedDataFile(value string, prefix string) bool { + if !strings.HasPrefix(value, prefix) || !strings.HasSuffix(value, ".dat") { + return false + } + + number := strings.TrimSuffix(strings.TrimPrefix(value, prefix), ".dat") + _, err := strconv.ParseUint(number, 10, 32) + return err == nil +} + +func fixedDigitDataFile(value string, digits int) bool { + if len(value) != digits+4 || !strings.HasSuffix(value, ".dat") { + return false + } + + _, err := strconv.ParseUint(value[:digits], 10, 32) + return err == nil +} + +func numberedExtensionFile(value string, extension string, max uint64) bool { + if !strings.HasSuffix(value, extension) { + return false + } + + parsed, err := strconv.ParseUint(strings.TrimSuffix(value, extension), 10, 32) + return err == nil && parsed <= max +} + +func decodeZoneData(data []byte, format ZoneDataFormat) (ZoneDataFile, error) { + result := ZoneDataFile{ + Format: format, + Capabilities: ZoneDataCapabilities{UpdateFields: true}, + } + + var err error + switch format { + case ZoneDataFormatMap: + result.Schema = mapSchema() + result.Map, err = decodeMap(data) + case ZoneDataFormatNPCSkill: + result.Schema = npcSkillSchema() + result.Rows, err = decodeNPCSkill(data) + case ZoneDataFormatNPCFavor: + result.Schema = npcFavorSchema() + result.Rows, err = decodeNPCFavor(data) + case ZoneDataFormatPCData: + result.Schema = pcDataSchema() + result.Rows, err = decodePCData(data) + case ZoneDataFormatSkillData: + result.Schema = skillDataSchema() + result.Rows, err = decodeSkillData(data) + case ZoneDataFormatSkillDelay: + result.Schema = skillDelaySchema() + result.Rows, err = decodeSkillDelay(data) + case ZoneDataFormatPassiveSkill: + result.Schema = passiveSkillSchema() + result.Rows, err = decodePassiveSkill(data) + case ZoneDataFormatHiredSoldierSkill: + result.Schema = hiredSoldierSkillSchema() + result.Rows, err = decodeHiredSoldierSkill(data) + case ZoneDataFormatCashItem, ZoneDataFormatSetItem, ZoneDataFormatPresentItemSet, ZoneDataFormatPet, + ZoneDataFormatShueCombination, ZoneDataFormatLottery, ZoneDataFormatDerbyGift, + ZoneDataFormatEventItemReward, ZoneDataFormatA3Present: + result.Schema, result.Rows, err = decodeEconomyZoneData(data, format) + case ZoneDataFormatMessage, ZoneDataFormatQuestEx, ZoneDataFormatSQuestQuiz, ZoneDataFormatTowerTreasure, + ZoneDataFormatOXQuiz, ZoneDataFormatTyrBase, ZoneDataFormatTyrPortal, ZoneDataFormatTyrUpgrade, + ZoneDataFormatTyrStartPoint, ZoneDataFormatTyrGift, ZoneDataFormatTyrNPCRegen, ZoneDataFormatTyrSkillLayer: + result.Schema, result.Rows, err = decodeRemainingZoneData(data, format) + default: + err = fmt.Errorf("unsupported ZoneData format %q", format) + } + + return result, err +} + +func row(index int, raw []byte, values map[string]any) ZoneDataRow { + return ZoneDataRow{Index: index, Values: values, OpaqueBytes: base64.StdEncoding.EncodeToString(raw)} +} + +func integerField(key string, label string, scope string, max int64) ZoneDataField { + min := int64(0) + return ZoneDataField{Key: key, Label: label, Type: "integer", Scope: scope, Editable: true, Min: &min, Max: &max} +} + +func boolField(key string, label string, scope string) ZoneDataField { + return ZoneDataField{Key: key, Label: label, Type: "boolean", Scope: scope, Editable: true} +} + +func stringField(key string, label string, scope string) ZoneDataField { + return ZoneDataField{Key: key, Label: label, Type: "string", Scope: scope, Editable: true} +} + +func writeZoneData(write func(io.Writer) error) ([]byte, error) { + var buffer bytes.Buffer + if err := write(&buffer); err != nil { + return nil, err + } + + return buffer.Bytes(), nil +} + +func operationUint(operation ZoneDataOperation, max uint64) (uint64, error) { + var value uint64 + if err := json.Unmarshal(operation.Value, &value); err != nil { + return 0, fmt.Errorf("%s must be an integer", operation.Field) + } + + if value > max { + return 0, fmt.Errorf("%s exceeds %d", operation.Field, max) + } + + return value, nil +} + +func operationInt32(operation ZoneDataOperation) (int32, error) { + var value int64 + if err := json.Unmarshal(operation.Value, &value); err != nil || value < -2147483648 || value > 2147483647 { + return 0, fmt.Errorf("%s must be a signed 32-bit integer", operation.Field) + } + + return int32(value), nil +} + +func validateRow(row int, length int) error { + if row < 0 || row >= length { + return fmt.Errorf("row %d out of range", row) + } + + return nil +} + +func verifyZoneData(original []byte, encoded []byte, allowed []bool, format ZoneDataFormat) error { + if len(original) != len(encoded) || len(allowed) != len(original) { + return fmt.Errorf("ZoneData byte length changed unexpectedly") + } + + for index := range original { + if original[index] != encoded[index] && !allowed[index] { + return fmt.Errorf("opaque byte %d changed", index) + } + } + + decoded, err := decodeZoneData(encoded, format) + if err != nil { + return fmt.Errorf("reparse ZoneData: %w", err) + } + + if decoded.Format != format { + return fmt.Errorf("ZoneData format changed unexpectedly") + } + + return nil +} + +func allowBytes(allowed []bool, offset int, size int) { + for index := offset; index < offset+size && index < len(allowed); index++ { + allowed[index] = true + } +} + +func fieldIndex(field string, prefix string, count int) (int, bool) { + if !strings.HasPrefix(field, prefix) { + return 0, false + } + + index, err := strconv.Atoi(strings.TrimPrefix(field, prefix)) + return index, err == nil && index >= 0 && index < count +} diff --git a/internal/services/zone_data_service_test.go b/internal/services/zone_data_service_test.go new file mode 100644 index 0000000..bd8ab74 --- /dev/null +++ b/internal/services/zone_data_service_test.go @@ -0,0 +1,183 @@ +package services + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/project-agonyl/agonyl-utils-go/lotteryfile" + "github.com/project-agonyl/agonyl-utils-go/npcskillfile" + "github.com/project-agonyl/agonyl-utils-go/questexfile" + "github.com/stretchr/testify/require" +) + +func TestZoneDataDetectRequiresConfiguredRootContainment(t *testing.T) { + root := t.TempDir() + inside := writeZoneDataTestFile(t, root, "ZoneData", "pc", "0") + outside := writeZoneDataTestFile(t, t.TempDir(), "ZoneData", "pc", "0") + service := &zoneDataService{} + + format, ok := service.Detect(root, inside) + require.True(t, ok) + require.Equal(t, ZoneDataFormatPCData, format) + resolvedRoot, err := filepath.EvalSymlinks(root) + require.NoError(t, err) + format, ok = service.DetectResolved(resolvedRoot, inside) + require.True(t, ok) + require.Equal(t, ZoneDataFormatPCData, format) + + _, ok = service.Detect(root, outside) + require.False(t, ok) +} + +func TestZoneDataDetectUsesRelativePathPrecedence(t *testing.T) { + root := t.TempDir() + pc := writeZoneDataTestFile(t, root, "ZoneData", "pc", "0") + skill := writeZoneDataTestFile(t, root, "ZoneData", "skill", "0") + item := writeZoneDataTestFile(t, root, "ZoneData", "item", "0") + service := &zoneDataService{} + + format, ok := service.Detect(root, pc) + require.True(t, ok) + require.Equal(t, ZoneDataFormatPCData, format) + format, ok = service.Detect(root, skill) + require.True(t, ok) + require.Equal(t, ZoneDataFormatSkillData, format) + _, ok = service.Detect(root, item) + require.False(t, ok) +} + +func TestIsZoneDataCandidatePath(t *testing.T) { + tests := map[string]bool{ + filepath.Join("ZoneData", "map", "1.map"): true, + filepath.Join("ZoneData", "npc", "NPCSkill"): true, + filepath.Join("ZoneData", "pc", "0"): true, + filepath.Join("ZoneData", "item", "PresentItemSet.dat"): true, + filepath.Join("ZoneData", "readme.txt"): false, + filepath.Join("logs", "zone.log"): false, + } + + for path, expected := range tests { + require.Equal(t, expected, IsZoneDataCandidatePath(path), path) + } +} + +func TestZoneDataApplyPreservesOpaqueNPCSkillBytes(t *testing.T) { + original := make([]byte, npcskillfile.RecordSize) + for index := range original { + original[index] = byte(index + 1) + } + service := &zoneDataService{} + operation := ZoneDataOperation{Scope: "row", Row: 0, Field: "effect_code", Value: json.RawMessage("48879")} + + updated, err := service.Apply(original, ZoneDataFormatNPCSkill, []ZoneDataOperation{operation}) + require.NoError(t, err) + require.Equal(t, original[:14], updated[:14]) + require.Equal(t, []byte{0xef, 0xbe}, updated[14:16]) + require.Equal(t, original[16:], updated[16:]) + + decoded, err := npcskillfile.Read(bytes.NewReader(updated)) + require.NoError(t, err) + require.Equal(t, uint16(0xbeef), decoded[0].EffectCode()) +} + +func TestZoneDataApplyRejectsUnregisteredField(t *testing.T) { + original := make([]byte, npcskillfile.RecordSize) + service := &zoneDataService{} + operation := ZoneDataOperation{Scope: "row", Row: 0, Field: "raw", Value: json.RawMessage("1")} + + _, err := service.Apply(original, ZoneDataFormatNPCSkill, []ZoneDataOperation{operation}) + require.Error(t, err) +} + +func TestZoneDataDetectsV06FormatsInsideServerRoot(t *testing.T) { + root := t.TempDir() + tests := []struct { + format ZoneDataFormat + parts []string + }{ + {ZoneDataFormatCashItem, []string{"ZoneData", "shop", "CashItemTbl.dat"}}, + {ZoneDataFormatSetItem, []string{"ZoneData", "item", "SIT2"}}, + {ZoneDataFormatPresentItemSet, []string{"ZoneData", "item", "PresentItemSet.dat"}}, + {ZoneDataFormatPet, []string{"ZoneData", "item", "pet"}}, + {ZoneDataFormatShueCombination, []string{"ZoneData", "item", "ShueCombinationData"}}, + {ZoneDataFormatDerbyGift, []string{"ZoneData", "npc", "DerbyGift.dat"}}, + {ZoneDataFormatLottery, []string{"Event", "LotteryItem.dat"}}, + {ZoneDataFormatEventItemReward, []string{"Event", "EventItem2.dat"}}, + {ZoneDataFormatA3Present, []string{"Present_3.dat"}}, + } + service := &zoneDataService{} + for _, test := range tests { + path := writeZoneDataTestFile(t, root, test.parts...) + format, ok := service.Detect(root, path) + require.True(t, ok, path) + require.Equal(t, test.format, format, path) + } +} + +func TestZoneDataApplyPreservesLotterySiblingMessages(t *testing.T) { + original := make([]byte, lotteryfile.RecordSize) + for index := range original { + original[index] = byte(index) + } + operation := ZoneDataOperation{Scope: "row", Row: 0, Field: "message_2", Value: json.RawMessage(`"winner"`)} + + updated, err := (&zoneDataService{}).Apply(original, ZoneDataFormatLottery, []ZoneDataOperation{operation}) + require.NoError(t, err) + offset := 4 + 2*lotteryfile.MessageSize + require.Equal(t, original[:offset], updated[:offset]) + require.Equal(t, original[offset+lotteryfile.MessageSize:], updated[offset+lotteryfile.MessageSize:]) +} + +func TestZoneDataDetectsV07BinaryFormats(t *testing.T) { + root := t.TempDir() + tests := []struct { + format ZoneDataFormat + parts []string + }{ + {ZoneDataFormatMessage, []string{"A3Msg_Zone_Tw.dat"}}, + {ZoneDataFormatQuestEx, []string{"ZoneData", "quest", "0042.dat"}}, + {ZoneDataFormatSQuestQuiz, []string{"ZoneData", "SQuest", "QuizTable.dat"}}, + {ZoneDataFormatTowerTreasure, []string{"Tower", "6.itm"}}, + {ZoneDataFormatOXQuiz, []string{"OXQuiz", "OXQuizTable.dat"}}, + {ZoneDataFormatTyrBase, []string{"ZoneData", "tyr", "BaseInfo.tyr"}}, + {ZoneDataFormatTyrPortal, []string{"ZoneData", "tyr", "WarpPortal.tyr"}}, + {ZoneDataFormatTyrUpgrade, []string{"ZoneData", "tyr", "Upgrade.tyr"}}, + {ZoneDataFormatTyrStartPoint, []string{"ZoneData", "tyr", "StartPoint.tyr"}}, + {ZoneDataFormatTyrGift, []string{"ZoneData", "tyr", "TyrGift.dat"}}, + {ZoneDataFormatTyrNPCRegen, []string{"ZoneData", "tyr", "NPCRegen.tyr"}}, + {ZoneDataFormatTyrSkillLayer, []string{"ZoneData", "tyr", "SkillLayer.tyr"}}, + } + service := &zoneDataService{} + for _, test := range tests { + path := writeZoneDataTestFile(t, root, test.parts...) + format, ok := service.Detect(root, path) + require.True(t, ok, path) + require.Equal(t, test.format, format, path) + } +} + +func TestZoneDataApplyPreservesQuestExOpaqueBytes(t *testing.T) { + var data questexfile.Data + require.NoError(t, data.SetInt32(0, 42)) + require.NoError(t, data.SetInt32(4, 1000)) + var original bytes.Buffer + require.NoError(t, questexfile.Write(&original, data)) + operation := ZoneDataOperation{Scope: "row", Row: 0, Field: "start_npc", Value: json.RawMessage("2000")} + + updated, err := (&zoneDataService{}).Apply(original.Bytes(), ZoneDataFormatQuestEx, []ZoneDataOperation{operation}) + require.NoError(t, err) + require.Equal(t, original.Bytes()[:4], updated[:4]) + require.Equal(t, original.Bytes()[8:], updated[8:]) +} + +func writeZoneDataTestFile(t *testing.T, root string, parts ...string) string { + t.Helper() + + path := filepath.Join(append([]string{root}, parts...)...) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755)) + require.NoError(t, os.WriteFile(path, nil, 0600)) + return path +}