Commit 5a0a8822 authored by Chunchi Che's avatar Chunchi Che

Merge branch 'feat/ai_predict' into 'main'

人机模式增加AI预测功能

See merge request mycard/Neos!390
parents 0541025d 7b5e4828
......@@ -37,6 +37,7 @@
"entertainWatchUrl": "wss://tiramisu.moecube.com:7923",
"userApi": "https://sapi.moecube.com:444/accounts/users/{username}.json",
"mdproServer": "https://rarnu.xyz:38443",
"agentServer": "https://t1v-n-e71fca19-w-0.tail0aad8.ts.net",
"streamInterval": 20,
"startDelay": 1000,
"ui": {
......
......@@ -37,6 +37,7 @@
"entertainWatchUrl": "wss://tiramisu.moecube.com:7923",
"userApi": "https://sapi.moecube.com:444/accounts/users/{username}.json",
"mdproServer": "https://rarnu.xyz:38443",
"agentServer": "https://t1v-n-e71fca19-w-0.tail0aad8.ts.net",
"streamInterval": 20,
"startDelay": 1000,
"ui": {
......
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -7,3 +7,16 @@ export * from "./ocgcore/idl/ocgcore";
export * from "./ocgcore/ocgHelper";
export * from "./strings";
export * from "./superPreRelease";
export * from "./ygoAgent";
export async function handleHttps<T>(
resp: Response,
api: string,
): Promise<T | undefined> {
if (!resp.ok) {
console.error(`Https error from api ${api}! status: ${resp.status}`);
return undefined;
} else {
return await resp.json();
}
}
import { useConfig } from "@/config";
import { handleHttps } from "..";
import { MdproResp } from "./schema";
import { handleHttps, mdproHeaders } from "./util";
import { mdproHeaders } from "./util";
const { mdproServer } = useConfig();
const API_PATH = "/api/mdpro3/sync/single";
......
import { useConfig } from "@/config";
import { handleHttps } from "..";
import { MdproResp } from "./schema";
import { handleHttps, mdproHeaders } from "./util";
import { mdproHeaders } from "./util";
const { mdproServer } = useConfig();
......
import { useConfig } from "@/config";
import { handleHttps } from "..";
import { MdproDeck, MdproResp } from "./schema";
import { handleHttps, mdproHeaders } from "./util";
import { mdproHeaders } from "./util";
const { mdproServer } = useConfig();
......
import { useConfig } from "@/config";
import { pfetch } from "@/infra";
import { handleHttps } from "..";
import { MdproDeck, MdproResp } from "./schema";
import { handleHttps, mdproHeaders } from "./util";
import { mdproHeaders } from "./util";
const { mdproServer } = useConfig();
const API_PATH = "/api/mdpro3/sync/";
......
import { useConfig } from "@/config";
import { pfetch } from "@/infra";
import { handleHttps } from "..";
import { MdproDeckLike, MdproResp } from "./schema";
import { handleHttps, mdproHeaders } from "./util";
import { mdproHeaders } from "./util";
const { mdproServer } = useConfig();
const API_PATH = "api/mdpro3/deck/list";
......
import { useConfig } from "@/config";
import { handleHttps } from "..";
import { MdproResp } from "./schema";
import { handleHttps, mdproHeaders } from "./util";
import { mdproHeaders } from "./util";
const { mdproServer } = useConfig();
const API_PATH = "/api/mdpro3/sync/single";
......
import { useConfig } from "@/config";
import { handleHttps } from "..";
import { MdproResp } from "./schema";
import { handleHttps, mdproHeaders } from "./util";
import { mdproHeaders } from "./util";
const { mdproServer } = useConfig();
const API_PATH = "/api/mdpro3/deck/public";
......
......@@ -5,17 +5,3 @@ export function mdproHeaders(): Headers {
return myHeaders;
}
export async function handleHttps<T>(
resp: Response,
api: string,
): Promise<T | undefined> {
if (!resp.ok) {
console.error(
`[Mdpro] Https error from api ${api}! status: ${resp.status}`,
);
return undefined;
} else {
return await resp.json();
}
}
......@@ -2,7 +2,8 @@
* 一些发ygopro协议数据包的辅助函数,用于简化业务代码。
*
* */
import socketMiddleWare, { socketCmd } from "@/middleware/socket";
import { WebSocketStream } from "@/infra";
import { sendSocketData } from "@/middleware/socket";
import { IDeck } from "@/stores";
import { ygopro } from "./idl/ocgcore";
......@@ -21,7 +22,7 @@ import TimeConfirm from "./ocgAdapter/ctos/ctosTimeConfirm";
import TpResult from "./ocgAdapter/ctos/ctosTpResult";
import UpdateDeckAdapter from "./ocgAdapter/ctos/ctosUpdateDeck";
export function sendUpdateDeck(deck: IDeck) {
export function sendUpdateDeck(conn: WebSocketStream, deck: IDeck) {
const updateDeck = new ygopro.YgoCtosMsg({
ctos_update_deck: new ygopro.CtosUpdateDeck({
main: deck.main,
......@@ -33,52 +34,52 @@ export function sendUpdateDeck(deck: IDeck) {
// FIXME: 如果要实现UI层和Adapter层解耦,这里应该不感知具体Adapter类型
const payload = new UpdateDeckAdapter(updateDeck).serialize();
socketMiddleWare({ cmd: socketCmd.SEND, payload });
sendSocketData(conn, payload);
}
export function sendHsReady() {
export function sendHsReady(conn: WebSocketStream) {
const hasReady = new ygopro.YgoCtosMsg({
ctos_hs_ready: new ygopro.CtosHsReady({}),
});
const payload = new HsReadyAdapter(hasReady).serialize();
socketMiddleWare({ cmd: socketCmd.SEND, payload });
sendSocketData(conn, payload);
}
export function sendHsNotReady() {
export function sendHsNotReady(conn: WebSocketStream) {
const hasNotReady = new ygopro.YgoCtosMsg({
ctos_hs_not_ready: new ygopro.CtosHsNotReady({}),
});
const payload = new HsNotReadyAdapter(hasNotReady).serialize();
socketMiddleWare({ cmd: socketCmd.SEND, payload });
sendSocketData(conn, payload);
}
export function sendHsToObserver() {
export function sendHsToObserver(conn: WebSocketStream) {
const hasToObserver = new ygopro.YgoCtosMsg({
ctos_hs_to_observer: new ygopro.CtosHsToObserver({}),
});
const payload = new HsToObserverAdapter(hasToObserver).serialize();
socketMiddleWare({ cmd: socketCmd.SEND, payload });
sendSocketData(conn, payload);
}
export function sendHsToDuelList() {
export function sendHsToDuelList(conn: WebSocketStream) {
const hasToDuelList = new ygopro.YgoCtosMsg({
ctos_hs_to_duel_list: new ygopro.CtosHsToDuelList({}),
});
const payload = new HsToDuelListAdapter(hasToDuelList).serialize();
socketMiddleWare({ cmd: socketCmd.SEND, payload });
sendSocketData(conn, payload);
}
export function sendHsStart() {
export function sendHsStart(conn: WebSocketStream) {
const hasStart = new ygopro.YgoCtosMsg({
ctos_hs_start: new ygopro.CtosHsStart({}),
});
const payload = new HsStartAdapter(hasStart).serialize();
socketMiddleWare({ cmd: socketCmd.SEND, payload });
sendSocketData(conn, payload);
}
export function sendPlayerInfo(ws: WebSocket, player: string) {
......@@ -105,7 +106,7 @@ export function sendJoinGame(ws: WebSocket, version: number, passWd: string) {
ws.send(packet.serialize());
}
export function sendHandResult(result: string) {
export function sendHandResult(conn: WebSocketStream, result: string) {
let hand = ygopro.HandType.UNKNOWN;
if (result === "scissors") {
hand = ygopro.HandType.SCISSORS;
......@@ -122,10 +123,10 @@ export function sendHandResult(result: string) {
});
const payload = new HandResult(handResult).serialize();
socketMiddleWare({ cmd: socketCmd.SEND, payload });
sendSocketData(conn, payload);
}
export function sendTpResult(isFirst: boolean) {
export function sendTpResult(conn: WebSocketStream, isFirst: boolean) {
let tp = ygopro.CtosTpResult.TpType.UNKNOWN;
if (isFirst) {
tp = ygopro.CtosTpResult.TpType.FIRST;
......@@ -140,37 +141,40 @@ export function sendTpResult(isFirst: boolean) {
});
const payload = new TpResult(tpResult).serialize();
socketMiddleWare({ cmd: socketCmd.SEND, payload });
sendSocketData(conn, payload);
}
export function sendTimeConfirm() {
export function sendTimeConfirm(conn: WebSocketStream) {
const timeConfirm = new ygopro.YgoCtosMsg({
ctos_time_confirm: new ygopro.CtosTimeConfirm({}),
});
const payload = new TimeConfirm(timeConfirm).serialize();
socketMiddleWare({ cmd: socketCmd.SEND, payload });
sendSocketData(conn, payload);
}
export function sendSurrender() {
export function sendSurrender(conn: WebSocketStream) {
const surrender = new ygopro.YgoCtosMsg({
ctos_surrender: new ygopro.CtosSurrender({}),
});
const payload = new Surrender(surrender).serialize();
socketMiddleWare({ cmd: socketCmd.SEND, payload });
sendSocketData(conn, payload);
}
export function sendChat(message: string) {
export function sendChat(conn: WebSocketStream, message: string) {
const chat = new ygopro.YgoCtosMsg({
ctos_chat: new ygopro.CtosChat({ message }),
});
const payload = new Chat(chat).serialize();
socketMiddleWare({ cmd: socketCmd.SEND, payload });
sendSocketData(conn, payload);
}
export function sendSelectIdleCmdResponse(value: number) {
export function sendSelectIdleCmdResponse(
conn: WebSocketStream,
value: number,
) {
const response = new ygopro.YgoCtosMsg({
ctos_response: new ygopro.CtosGameMsgResponse({
select_idle_cmd: new ygopro.CtosGameMsgResponse.SelectIdleCmdResponse({
......@@ -180,14 +184,17 @@ export function sendSelectIdleCmdResponse(value: number) {
});
const payload = new GameMsgResponse(response).serialize();
socketMiddleWare({ cmd: socketCmd.SEND, payload });
sendSocketData(conn, payload);
}
export function sendSelectPlaceResponse(value: {
controller: number;
zone: ygopro.CardZone;
sequence: number;
}) {
export function sendSelectPlaceResponse(
conn: WebSocketStream,
value: {
controller: number;
zone: ygopro.CardZone;
sequence: number;
},
) {
const response = new ygopro.YgoCtosMsg({
ctos_response: new ygopro.CtosGameMsgResponse({
select_place: new ygopro.CtosGameMsgResponse.SelectPlaceResponse({
......@@ -199,10 +206,13 @@ export function sendSelectPlaceResponse(value: {
});
const payload = new GameMsgResponse(response).serialize();
socketMiddleWare({ cmd: socketCmd.SEND, payload });
sendSocketData(conn, payload);
}
export function sendSelectMultiResponse(value: number[]) {
export function sendSelectMultiResponse(
conn: WebSocketStream,
value: number[],
) {
const response = new ygopro.YgoCtosMsg({
ctos_response: new ygopro.CtosGameMsgResponse({
select_multi: new ygopro.CtosGameMsgResponse.SelectMultiResponse({
......@@ -212,10 +222,10 @@ export function sendSelectMultiResponse(value: number[]) {
});
const payload = new GameMsgResponse(response).serialize();
socketMiddleWare({ cmd: socketCmd.SEND, payload });
sendSocketData(conn, payload);
}
export function sendSelectSingleResponse(value: number) {
export function sendSelectSingleResponse(conn: WebSocketStream, value: number) {
const response = new ygopro.YgoCtosMsg({
ctos_response: new ygopro.CtosGameMsgResponse({
select_single: new ygopro.CtosGameMsgResponse.SelectSingleResponse({
......@@ -225,10 +235,13 @@ export function sendSelectSingleResponse(value: number) {
});
const payload = new GameMsgResponse(response).serialize();
socketMiddleWare({ cmd: socketCmd.SEND, payload });
sendSocketData(conn, payload);
}
export function sendSelectEffectYnResponse(value: boolean) {
export function sendSelectEffectYnResponse(
conn: WebSocketStream,
value: boolean,
) {
const response = new ygopro.YgoCtosMsg({
ctos_response: new ygopro.CtosGameMsgResponse({
select_effect_yn: new ygopro.CtosGameMsgResponse.SelectEffectYnResponse({
......@@ -238,10 +251,13 @@ export function sendSelectEffectYnResponse(value: boolean) {
});
const payload = new GameMsgResponse(response).serialize();
socketMiddleWare({ cmd: socketCmd.SEND, payload });
sendSocketData(conn, payload);
}
export function sendSelectPositionResponse(value: ygopro.CardPosition) {
export function sendSelectPositionResponse(
conn: WebSocketStream,
value: ygopro.CardPosition,
) {
const response = new ygopro.YgoCtosMsg({
ctos_response: new ygopro.CtosGameMsgResponse({
select_position: new ygopro.CtosGameMsgResponse.SelectPositionResponse({
......@@ -251,10 +267,10 @@ export function sendSelectPositionResponse(value: ygopro.CardPosition) {
});
const payload = new GameMsgResponse(response).serialize();
socketMiddleWare({ cmd: socketCmd.SEND, payload });
sendSocketData(conn, payload);
}
export function sendSelectOptionResponse(value: number) {
export function sendSelectOptionResponse(conn: WebSocketStream, value: number) {
const response = new ygopro.YgoCtosMsg({
ctos_response: new ygopro.CtosGameMsgResponse({
select_option: new ygopro.CtosGameMsgResponse.SelectOptionResponse({
......@@ -264,10 +280,13 @@ export function sendSelectOptionResponse(value: number) {
});
const payload = new GameMsgResponse(response).serialize();
socketMiddleWare({ cmd: socketCmd.SEND, payload });
sendSocketData(conn, payload);
}
export function sendSelectBattleCmdResponse(value: number) {
export function sendSelectBattleCmdResponse(
conn: WebSocketStream,
value: number,
) {
const response = new ygopro.YgoCtosMsg({
ctos_response: new ygopro.CtosGameMsgResponse({
select_battle_cmd: new ygopro.CtosGameMsgResponse.SelectBattleCmdResponse(
......@@ -279,10 +298,13 @@ export function sendSelectBattleCmdResponse(value: number) {
});
const payload = new GameMsgResponse(response).serialize();
socketMiddleWare({ cmd: socketCmd.SEND, payload });
sendSocketData(conn, payload);
}
export function sendSelectCounterResponse(counts: number[]) {
export function sendSelectCounterResponse(
conn: WebSocketStream,
counts: number[],
) {
const response = new ygopro.YgoCtosMsg({
ctos_response: new ygopro.CtosGameMsgResponse({
select_counter_response:
......@@ -293,10 +315,13 @@ export function sendSelectCounterResponse(counts: number[]) {
});
const payload = new GameMsgResponse(response).serialize();
socketMiddleWare({ cmd: socketCmd.SEND, payload });
sendSocketData(conn, payload);
}
export function sendSortCardResponse(sortedIndexes: number[]) {
export function sendSortCardResponse(
conn: WebSocketStream,
sortedIndexes: number[],
) {
const response = new ygopro.YgoCtosMsg({
ctos_response: new ygopro.CtosGameMsgResponse({
sort_card: new ygopro.CtosGameMsgResponse.SortCardResponse({
......@@ -306,5 +331,5 @@ export function sendSortCardResponse(sortedIndexes: number[]) {
});
const payload = new GameMsgResponse(response).serialize();
socketMiddleWare({ cmd: socketCmd.SEND, payload });
sendSocketData(conn, payload);
}
import { useConfig } from "@/config";
import { handleHttps } from "..";
import { agentHeader } from "./util";
const { agentServer } = useConfig();
const API_PATH = "v0/duels";
interface CreateResp {
duelId: string;
index: number;
}
export async function createDuel(): Promise<CreateResp | undefined> {
const headers = agentHeader();
const resp = await fetch(`${agentServer}/${API_PATH}`, {
method: "POST",
headers,
redirect: "follow",
});
return await handleHttps(resp, API_PATH);
}
import { useConfig } from "@/config";
import { handleHttps } from "..";
import { agentHeader } from "./util";
const { agentServer } = useConfig();
const API_PATH = "/v0/duels";
export async function deleteDuel(duelId: string): Promise<void | undefined> {
const headers = agentHeader();
const apiPath = `${agentServer}/${API_PATH}/${duelId}`;
const resp = await fetch(apiPath, {
method: "DELETE",
headers,
redirect: "follow",
});
return await handleHttps(resp, apiPath);
}
export * from "./create";
export * from "./delete";
export * from "./predict";
export * from "./transaction";
import { useConfig } from "@/config";
import { handleHttps } from "..";
import { Input, MsgResponse } from "./schema";
import { agentHeader } from "./util";
const { agentServer } = useConfig();
const apiPath = (duelId: string) => `v0/duels/${duelId}/predict`;
export interface PredictReq {
/**
* The index must be equal to the index from the previous response of the same duelId.
*/
index: number;
input: Input;
prev_action_idx: number;
}
interface PredictResp {
/**
* It will be equal to the request's index + 1.
*/
index: number;
predict_results: MsgResponse;
}
export async function predictDuel(
duelId: string,
req: PredictReq,
): Promise<PredictResp | undefined> {
const headers = {
...agentHeader(),
"Content-Type": "application/json",
};
const resp = await fetch(`${agentServer}/${apiPath(duelId)}`, {
method: "POST",
headers,
body: JSON.stringify(req),
redirect: "follow",
});
return await handleHttps(resp, apiPath(duelId));
}
// Data schema for YgoAgent Service
/**
* none for N/A or unknown or token.
*/
export enum Attribute {
None = "none",
Earth = "earth",
Water = "water",
Fire = "fire",
Wind = "wind",
Light = "light",
Dark = "dark",
Divine = "divine",
}
export enum Controller {
Me = "me",
Opponent = "opponent",
}
//
export enum Location {
Deck = "deck",
Extra = "extra",
Grave = "grave",
Hand = "hand",
MZone = "mzone",
Removed = "removed",
SZone = "szone",
}
interface Place {
controller: Controller;
location: Location;
/**
* Start from 0
*/
sequence: number;
}
interface Option {
code: number;
}
export interface CardLocation {
controller: Controller;
location: Location;
/**
* if is overlay, this is the overlay index, starting from 0, else -1.
*/
overlay_sequence: number;
/**
* Start from 0
*/
sequence: number;
}
/**
* If the monster is xyz material (overlay_sequence != -1), the position is faceup.
*/
export enum Position {
None = "none",
FaceupAttack = "faceup_attack",
FacedownAttack = "facedown_attack",
Attack = "attack",
FaceupDefense = "faceup_defense",
Faceup = "faceup",
FacedownDefense = "facedown_defense",
Facedown = "facedown",
Defense = "defense",
}
/**
* none for N/A or unknown or token.
*/
export enum Race {
Aqua = "aqua",
Beast = "beast",
BeastWarrior = "beast_warrior",
CreatorGod = "creator_god",
Cyberse = "cyberse",
Devine = "devine",
Dinosaur = "dinosaur",
Dragon = "dragon",
Fairy = "fairy",
Fiend = "fiend",
Fish = "fish",
Illusion = "illusion",
Insect = "insect",
Machine = "machine",
None = "none",
Plant = "plant",
Psycho = "psycho",
Pyro = "pyro",
Reptile = "reptile",
Rock = "rock",
SeaSerpent = "sea_serpent",
Spellcaster = "spellcaster",
Thunder = "thunder",
Warrior = "warrior",
Windbeast = "windbeast",
Wyrm = "wyrm",
Zombie = "zombie",
}
export enum Type {
Continuous = "continuous",
Counter = "counter",
Dual = "dual",
Effect = "effect",
Equip = "equip",
Field = "field",
Flip = "flip",
Fusion = "fusion",
Link = "link",
Monster = "monster",
Normal = "normal",
Pendulum = "pendulum",
QuickPlay = "quick_play",
Ritual = "ritual",
Special = "special",
Spell = "spell",
Spirit = "spirit",
Synchro = "synchro",
Token = "token",
Toon = "toon",
Trap = "trap",
TrapMonster = "trap_monster",
Tuner = "tuner",
Union = "union",
Xyz = "xyz",
}
export interface Card {
/**
* Card code from cards.cdb
*/
code: number;
location: Location;
/**
* Sequence in ocgcore, 0 is N/A or unknown, if not, shoud start from 1. Only non-zero for
* cards in mzone, szone and grave.
*/
sequence: number;
controller: Controller;
/**
* If the monster is xyz material (overlay_sequence != -1), the position is faceup.
*/
position: Position;
/**
* if is overlay, this is the overlay index, starting from 0, else -1.
*/
overlay_sequence: number;
/**
* none for N/A or unknown or token.
*/
attribute: Attribute;
/**
* none for N/A or unknown or token.
*/
race: Race;
/**
* Rank and link are also considered as level. 0 is N/A or unknown.
*/
level: number;
/**
* Number of counters. If there are 2 types of counters or more, we consider only the first
* type of counter.
*/
counter: number;
/**
* Whether the card effect is disabled or forbidden
*/
negated: boolean;
attack: number;
defense: number;
types: Type[];
}
export enum Phase {
Battle = "battle",
BattleStart = "battle_start",
BattleStep = "battle_step",
Damage = "damage",
DamageCalculation = "damage_calculation",
Draw = "draw",
End = "end",
Main1 = "main1",
Main2 = "main2",
Standby = "standby",
}
export interface Global {
/**
* Whether me is the first player
*/
is_first: boolean;
is_my_turn: boolean;
my_lp: number;
op_lp: number;
phase: Phase;
turn: number;
}
interface SelectAbleCard {
location: CardLocation;
response: number;
}
export interface MsgSelectCard {
msg_type: "select_card";
cancelable: boolean;
min: number;
max: number;
cards: SelectAbleCard[];
selected: number[];
}
export type MultiSelectMsg = MsgSelectCard | MsgSelectSum | MsgSelectTribute;
interface SelectTributeCard {
location: CardLocation;
level: number;
response: number;
}
export interface MsgSelectTribute {
msg_type: "select_tribute";
cancelable: boolean;
min: number;
max: number;
cards: SelectTributeCard[];
selected: number[];
}
interface SelectSumCard {
location: CardLocation;
level1: number;
level2: number;
response: number;
}
export interface MsgSelectSum {
msg_type: "select_sum";
overflow: boolean;
level_sum: number;
min: number;
max: number;
cards: SelectSumCard[];
must_cards: SelectSumCard[];
selected: number[];
}
export interface CardInfo {
code: number;
controller: Controller;
location: Location;
sequence: number;
}
export enum IdleCmdType {
Summon = "summon",
SpSummon = "sp_summon",
Reposition = "reposition",
Mset = "mset",
Set = "set",
Activate = "activate",
ToBp = "to_bp",
ToEp = "to_ep",
}
interface IdleCmdData {
card_info: CardInfo;
effect_description: number;
response: number;
}
export interface IdleCmd {
cmd_type: IdleCmdType;
data?: IdleCmdData;
}
export interface MsgSelectIdleCmd {
msg_type: "select_idlecmd";
idle_cmds: IdleCmd[];
}
export interface Chain {
code: number;
location: CardLocation;
effect_description: number;
response: number;
}
export interface MsgSelectChain {
msg_type: "select_chain";
forced: boolean;
chains: Chain[];
}
export interface MsgSelectPosition {
msg_type: "select_position";
code: number;
positions: Position[];
}
export interface MsgSelectYesNo {
msg_type: "select_yesno";
effect_description: number;
}
export interface MsgSelectEffectYn {
msg_type: "select_effectyn";
code: number;
location: CardLocation;
effect_description: number;
}
export enum BattleCmdType {
Attack = "attack",
Activate = "activate",
ToM2 = "to_m2",
ToEp = "to_ep",
}
export interface BattleCmdData {
card_info: CardInfo;
effect_description: number;
direct_attackable: boolean;
response: number;
}
export interface BattleCmd {
cmd_type: BattleCmdType;
data?: BattleCmdData;
}
export interface MsgSelectBattleCmd {
msg_type: "select_battlecmd";
battle_cmds: BattleCmd[];
}
export interface SelectUnselectCard {
location: CardLocation;
response: number;
}
export interface MsgSelectUnselectCard {
msg_type: "select_unselect_card";
finishable: boolean;
cancelable: boolean;
min: number;
max: number;
selected_cards: SelectUnselectCard[];
selectable_cards: SelectUnselectCard[];
}
interface Option {
code: number;
response: number;
}
export interface MsgSelectOption {
msg_type: "select_option";
options: Option[];
}
interface Place {
controller: Controller;
location: Location;
sequence: number;
}
export interface MsgSelectPlace {
msg_type: "select_place";
count: number;
places: Place[];
}
interface AnnounceAttrib {
attribute: Attribute;
response: number;
}
export interface MsgAnnounceAttrib {
msg_type: "announce_attrib";
count: number;
attributes: AnnounceAttrib[];
}
interface AnnounceNumber {
number: number;
response: number;
}
export interface MsgAnnounceNumber {
msg_type: "announce_number";
count: number;
numbers: AnnounceNumber[];
}
type ActionMsgData =
| MsgSelectCard
| MsgSelectTribute
| MsgSelectSum
| MsgSelectIdleCmd
| MsgSelectChain
| MsgSelectPosition
| MsgSelectYesNo
| MsgSelectEffectYn
| MsgSelectBattleCmd
| MsgSelectUnselectCard
| MsgSelectOption
| MsgSelectPlace
| MsgAnnounceAttrib
| MsgAnnounceNumber;
export interface ActionMsg {
data: ActionMsgData;
}
export interface Input {
action_msg: ActionMsg;
cards: Card[];
global: Global;
}
interface ActionPredict {
prob: number;
response: number;
can_finish: boolean;
}
export interface MsgResponse {
action_preds: ActionPredict[];
win_rate: number;
}
import { CardMeta, ygopro } from "@/api";
import {
ATTRIBUTE_DARK,
ATTRIBUTE_DEVINE,
ATTRIBUTE_EARTH,
ATTRIBUTE_FIRE,
ATTRIBUTE_LIGHT,
ATTRIBUTE_WATER,
ATTRIBUTE_WIND,
FACEDOWN_ATTACK,
FACEDOWN_DEFENSE,
FACEUP_ATTACK,
FACEUP_DEFENSE,
RACE_AQUA,
RACE_BEAST,
RACE_BEASTWARRIOR,
RACE_CREATORGOD,
RACE_CYBERSE,
RACE_DEVINE,
RACE_DINOSAUR,
RACE_DRAGON,
RACE_FAIRY,
RACE_FIEND,
RACE_FISH,
RACE_INSECT,
RACE_MACHINE,
RACE_PLANT,
RACE_PSYCHO,
RACE_PYRO,
RACE_REPTILE,
RACE_ROCK,
RACE_SEASERPENT,
RACE_SPELLCASTER,
RACE_THUNDER,
RACE_WARRIOR,
RACE_WINDBEAST,
RACE_WYRM,
RACE_ZOMBIE,
TYPE_CONTINUOUS,
TYPE_COUNTER,
TYPE_DUAL,
TYPE_EFFECT,
TYPE_EQUIP,
TYPE_FIELD,
TYPE_FLIP,
TYPE_FUSION,
TYPE_LINK,
TYPE_MONSTER,
TYPE_NORMAL,
TYPE_PENDULUM,
TYPE_QUICKPLAY,
TYPE_RITUAL,
TYPE_SPELL,
TYPE_SPIRIT,
TYPE_SPSUMMON,
TYPE_SYNCHRO,
TYPE_TOKEN,
TYPE_TOON,
TYPE_TRAP,
TYPE_TRAPMONSTER,
TYPE_TUNER,
TYPE_UNION,
TYPE_XYZ,
} from "@/common";
import { extraCardTypes } from "@/common";
import { CardType } from "@/stores/cardStore";
import {
ActionMsg,
Attribute,
BattleCmd,
BattleCmdData,
BattleCmdType,
Card,
CardInfo,
CardLocation,
Chain,
Controller,
IdleCmd,
IdleCmdType,
Location,
MsgAnnounceAttrib,
MsgAnnounceNumber,
MsgSelectBattleCmd,
MsgSelectCard,
MsgSelectChain,
MsgSelectEffectYn,
MsgSelectIdleCmd,
MsgSelectOption,
MsgSelectPlace,
MsgSelectPosition,
MsgSelectSum,
MsgSelectTribute,
MsgSelectUnselectCard,
MsgSelectYesNo,
Phase,
Position,
Race,
Type,
} from "./schema";
import GM = ygopro.StocGameMessage;
import _Phase = GM.MsgNewPhase.PhaseType;
import _IdleType = GM.MsgSelectIdleCmd.IdleCmd.IdleType;
import _BattleCmdType = GM.MsgSelectBattleCmd.BattleCmd.BattleType;
// from common.ts ATTRIBUTE_*
function numberToAttribute(attributeNumber: number): Attribute {
switch (attributeNumber) {
case 0x00:
return Attribute.None;
case ATTRIBUTE_EARTH:
return Attribute.Earth;
case ATTRIBUTE_WATER:
return Attribute.Water;
case ATTRIBUTE_FIRE:
return Attribute.Fire;
case ATTRIBUTE_WIND:
return Attribute.Wind;
case ATTRIBUTE_LIGHT:
return Attribute.Light;
case ATTRIBUTE_DARK:
return Attribute.Dark;
case ATTRIBUTE_DEVINE:
return Attribute.Divine;
default:
throw new Error(`Unknown attribute number: ${attributeNumber}`);
}
}
function cardZoneToLocation(zone: ygopro.CardZone): Location {
switch (zone) {
case ygopro.CardZone.DECK:
return Location.Deck;
case ygopro.CardZone.HAND:
return Location.Hand;
case ygopro.CardZone.MZONE:
return Location.MZone;
case ygopro.CardZone.SZONE:
return Location.SZone;
case ygopro.CardZone.GRAVE:
return Location.Grave;
case ygopro.CardZone.REMOVED:
return Location.Removed;
case ygopro.CardZone.EXTRA:
return Location.Extra;
default:
throw new Error(`Unknown card zone: ${zone}`);
}
}
function convertController(controller: number, player: number): Controller {
return controller === player ? Controller.Me : Controller.Opponent;
}
function convertOverlaySequence(cl: ygopro.CardLocation): number {
return cl.is_overlay ? cl.overlay_sequence : -1;
}
function convertCardLocation(
cl: ygopro.CardLocation,
player: number,
): CardLocation {
return {
controller: convertController(cl.controller, player),
location: cardZoneToLocation(cl.zone),
overlay_sequence: convertOverlaySequence(cl),
sequence: cl.sequence,
};
}
function convertPosition(position: ygopro.CardPosition): Position {
switch (position) {
case ygopro.CardPosition.FACEUP_ATTACK:
return Position.FaceupAttack;
case ygopro.CardPosition.FACEDOWN_ATTACK:
return Position.FacedownAttack;
case ygopro.CardPosition.FACEUP_DEFENSE:
return Position.FaceupDefense;
case ygopro.CardPosition.FACEDOWN_DEFENSE:
return Position.FacedownDefense;
case ygopro.CardPosition.FACEUP:
return Position.Faceup;
case ygopro.CardPosition.FACEDOWN:
return Position.Facedown;
case ygopro.CardPosition.ATTACK:
return Position.Attack;
case ygopro.CardPosition.DEFENSE:
return Position.Defense;
default:
throw new Error(`Unknown card position: ${position}`);
}
}
function numberToRace(raceNumber: number): Race {
switch (raceNumber) {
case 0x0:
return Race.None;
case RACE_WARRIOR:
return Race.Warrior;
case RACE_SPELLCASTER:
return Race.Spellcaster;
case RACE_FAIRY:
return Race.Fairy;
case RACE_FIEND:
return Race.Fiend;
case RACE_ZOMBIE:
return Race.Zombie;
case RACE_MACHINE:
return Race.Machine;
case RACE_AQUA:
return Race.Aqua;
case RACE_PYRO:
return Race.Pyro;
case RACE_ROCK:
return Race.Rock;
case RACE_WINDBEAST:
return Race.Windbeast;
case RACE_PLANT:
return Race.Plant;
case RACE_INSECT:
return Race.Insect;
case RACE_THUNDER:
return Race.Thunder;
case RACE_DRAGON:
return Race.Dragon;
case RACE_BEAST:
return Race.Beast;
case RACE_BEASTWARRIOR:
return Race.BeastWarrior;
case RACE_DINOSAUR:
return Race.Dinosaur;
case RACE_FISH:
return Race.Fish;
case RACE_SEASERPENT:
return Race.SeaSerpent;
case RACE_REPTILE:
return Race.Reptile;
case RACE_PSYCHO:
return Race.Psycho;
case RACE_DEVINE:
return Race.Devine;
case RACE_CREATORGOD:
return Race.CreatorGod;
case RACE_WYRM:
return Race.Wyrm;
case RACE_CYBERSE:
return Race.Cyberse;
default:
throw new Error(`Unknown race number: ${raceNumber}`);
}
}
function numberToType(typeNumber: number): Type {
switch (typeNumber) {
case TYPE_MONSTER:
return Type.Monster;
case TYPE_SPELL:
return Type.Spell;
case TYPE_TRAP:
return Type.Trap;
case TYPE_NORMAL:
return Type.Normal;
case TYPE_EFFECT:
return Type.Effect;
case TYPE_FUSION:
return Type.Fusion;
case TYPE_RITUAL:
return Type.Ritual;
case TYPE_TRAPMONSTER:
return Type.TrapMonster;
case TYPE_SPIRIT:
return Type.Spirit;
case TYPE_UNION:
return Type.Union;
case TYPE_DUAL:
return Type.Dual;
case TYPE_TUNER:
return Type.Tuner;
case TYPE_SYNCHRO:
return Type.Synchro;
case TYPE_TOKEN:
return Type.Token;
case TYPE_QUICKPLAY:
return Type.QuickPlay;
case TYPE_CONTINUOUS:
return Type.Continuous;
case TYPE_EQUIP:
return Type.Equip;
case TYPE_FIELD:
return Type.Field;
case TYPE_COUNTER:
return Type.Counter;
case TYPE_FLIP:
return Type.Flip;
case TYPE_TOON:
return Type.Toon;
case TYPE_XYZ:
return Type.Xyz;
case TYPE_PENDULUM:
return Type.Pendulum;
case TYPE_SPSUMMON:
return Type.Special;
case TYPE_LINK:
return Type.Link;
default:
throw new Error(`Unknown type number: ${typeNumber}`);
}
}
function getCounter(counters: { [type: number]: number }) {
if (counters) {
for (const type in counters) {
return counters[type];
}
}
return 0;
}
export function convertDeckCard(meta: CardMeta): Card {
return {
code: meta.id,
location: Location.Deck,
sequence: 0,
controller: Controller.Me,
position: Position.Facedown,
overlay_sequence: -1,
attribute: numberToAttribute(meta.data.attribute ?? 0),
race: numberToRace(meta.data.race ?? 0),
level: meta.data.level ?? 0,
counter: 0,
negated: false,
attack: meta.data.atk ?? 0,
defense: meta.data.def ?? 0,
types: extraCardTypes(meta.data.type ?? 0).map(numberToType),
};
}
export function convertCard(card: CardType, player: number): Card {
// TODO (ygo-agent): opponent's visible facedown cards (confirm_cards)
const { code, location, meta, counters } = card;
return {
code,
location: cardZoneToLocation(location.zone),
sequence: location.sequence,
controller: convertController(location.controller, player),
position: convertPosition(location.position),
overlay_sequence: convertOverlaySequence(location),
attribute: numberToAttribute(meta.data.attribute ?? 0),
race: numberToRace(meta.data.race ?? 0),
level: meta.data.level ?? 0,
counter: getCounter(counters),
// TODO (ygo-agent): add negated
negated: false,
attack: meta.data.atk ?? 0,
defense: meta.data.def ?? 0,
types: extraCardTypes(meta.data.type ?? 0).map(numberToType),
};
}
export function convertPhase(phase: _Phase): Phase {
switch (phase) {
case _Phase.DRAW:
return Phase.Draw;
case _Phase.STANDBY:
return Phase.Standby;
case _Phase.MAIN1:
return Phase.Main1;
case _Phase.BATTLE_START:
return Phase.BattleStart;
case _Phase.BATTLE_STEP:
return Phase.BattleStep;
case _Phase.DAMAGE_GAL:
return Phase.DamageCalculation;
case _Phase.DAMAGE:
return Phase.Damage;
case _Phase.BATTLE:
return Phase.Battle;
case _Phase.MAIN2:
return Phase.Main2;
case _Phase.END:
return Phase.End;
default:
throw new Error(`Unknown phase: ${phase}`);
}
}
export function parsePlayerFromMsg(msg: GM): number {
switch (msg.gameMsg) {
case "select_card":
return msg.select_card.player;
case "select_tribute":
return msg.select_tribute.player;
case "select_sum":
return msg.select_sum.player;
case "select_idle_cmd":
return msg.select_idle_cmd.player;
case "select_chain":
return msg.select_chain.player;
case "select_position":
return msg.select_position.player;
case "select_effect_yn":
return msg.select_effect_yn.player;
case "select_yes_no":
return msg.select_yes_no.player;
case "select_battle_cmd":
return msg.select_battle_cmd.player;
case "select_unselect_card":
return msg.select_unselect_card.player;
case "select_option":
return msg.select_option.player;
case "select_place":
return msg.select_place.player;
case "announce":
return msg.announce.player;
default:
throw new Error(`Unsupported message type: ${msg}`);
}
}
function convertMsgSelectCard(msg: GM.MsgSelectCard): MsgSelectCard {
// response is -1 for finish
return {
msg_type: "select_card",
cancelable: msg.cancelable,
min: msg.min,
max: msg.max,
cards: msg.cards.map((c) => ({
location: convertCardLocation(c.location, msg.player),
response: c.response,
})),
selected: [],
};
}
function convertMsgSelectTribute(msg: GM.MsgSelectTribute): MsgSelectTribute {
return {
msg_type: "select_tribute",
cancelable: msg.cancelable,
min: msg.min,
max: msg.max,
cards: msg.selectable_cards.map((c) => ({
location: convertCardLocation(c.location, msg.player),
level: c.level,
response: c.response,
})),
selected: [],
};
}
function convertMsgSelectSum(msg: GM.MsgSelectSum): MsgSelectSum {
return {
msg_type: "select_sum",
overflow: msg.overflow !== 0,
level_sum: msg.level_sum,
min: msg.min,
max: msg.max,
cards: msg.selectable_cards.map((c) => ({
location: convertCardLocation(c.location, msg.player),
level1: c.level1,
level2: c.level2,
response: c.response,
})),
must_cards: msg.must_select_cards.map((c) => ({
location: convertCardLocation(c.location, msg.player),
level1: c.level1,
level2: c.level2,
response: c.response,
})),
selected: [],
};
}
function convertCardInfo(cardInfo: ygopro.CardInfo, player: number): CardInfo {
return {
code: cardInfo.code,
controller: convertController(cardInfo.controller, player),
location: cardZoneToLocation(cardInfo.location),
sequence: cardInfo.sequence,
};
}
function convertIdleCmdType(cmdType: _IdleType): IdleCmdType {
switch (cmdType) {
case _IdleType.ACTIVATE:
return IdleCmdType.Activate;
case _IdleType.MSET:
return IdleCmdType.Mset;
case _IdleType.POS_CHANGE:
return IdleCmdType.Reposition;
case _IdleType.SSET:
return IdleCmdType.Set;
case _IdleType.SPSUMMON:
return IdleCmdType.SpSummon;
case _IdleType.SUMMON:
return IdleCmdType.Summon;
case _IdleType.TO_BP:
return IdleCmdType.ToBp;
case _IdleType.TO_EP:
return IdleCmdType.ToEp;
default:
throw new Error(`Unknown idle command type: ${cmdType}`);
}
}
function convertMsgSelectIdleCmd(msg: GM.MsgSelectIdleCmd): MsgSelectIdleCmd {
const idle_cmds: IdleCmd[] = [];
for (const cmd of msg.idle_cmds) {
for (const data of cmd.idle_datas) {
const cmd_type = convertIdleCmdType(cmd.idle_type);
if (
cmd_type === IdleCmdType.Summon ||
cmd_type === IdleCmdType.SpSummon ||
cmd_type === IdleCmdType.Reposition ||
cmd_type === IdleCmdType.Mset ||
cmd_type === IdleCmdType.Set ||
cmd_type === IdleCmdType.Activate
) {
idle_cmds.push({
cmd_type,
data: {
card_info: convertCardInfo(data.card_info, msg.player),
effect_description:
cmd_type === IdleCmdType.Activate ? data.effect_description : 0,
response: data.response,
},
});
} else {
throw new Error(`Unsupported idle command type: ${cmd_type}`);
}
}
}
if (msg.enable_bp) {
// response will be 6
idle_cmds.push({ cmd_type: IdleCmdType.ToBp });
}
// TODO (ygo-agent): new models will support it
if (msg.enable_ep && !msg.enable_bp) {
// response will be 7
idle_cmds.push({ cmd_type: IdleCmdType.ToEp });
}
return {
msg_type: "select_idlecmd",
idle_cmds: idle_cmds,
};
}
function convertChain(chain: GM.MsgSelectChain.Chain, player: number): Chain {
return {
code: chain.code,
location: convertCardLocation(chain.location, player),
effect_description: chain.effect_description,
response: chain.response,
};
}
function convertMsgSelectChain(msg: GM.MsgSelectChain): MsgSelectChain {
// response is -1 for cancel
return {
msg_type: "select_chain",
forced: msg.forced,
chains: msg.chains.map((c) => convertChain(c, msg.player)),
};
}
function convertMsgSelectPosition(
msg: GM.MsgSelectPosition,
): MsgSelectPosition {
return {
msg_type: "select_position",
code: msg.code,
// response will be equal to POS_* from ocgcore
// POS_FACEUP_ATTACK: 0x1, POS_FACEDOWN_ATTACK: 0x2,
// POS_FACEUP_DEFENSE: 0x4, POS_FACEDOWN_DEFENSE: 0x8
positions: msg.positions.map((p) => convertPosition(p.position)),
};
}
function convertMsgSelectYesNo(msg: GM.MsgSelectYesNo): MsgSelectYesNo {
// response is 1 for yes and 0 for no
return {
msg_type: "select_yesno",
effect_description: msg.effect_description,
};
}
function convertMsgSelectEffectYn(
msg: GM.MsgSelectEffectYn,
): MsgSelectEffectYn {
// response is 1 for yes and 0 for no
return {
msg_type: "select_effectyn",
code: msg.code,
location: convertCardLocation(msg.location, msg.player),
effect_description: msg.effect_description,
};
}
function convertBattleCmdType(cmdType: _BattleCmdType): BattleCmdType {
switch (cmdType) {
case _BattleCmdType.ATTACK:
return BattleCmdType.Attack;
case _BattleCmdType.ACTIVATE:
return BattleCmdType.Activate;
default:
throw new Error(`Unknown battle command type: ${cmdType}`);
}
}
function convertMsgSelectBattleCmd(
msg: GM.MsgSelectBattleCmd,
): MsgSelectBattleCmd {
const battle_cmds: BattleCmd[] = [];
for (const cmd of msg.battle_cmds) {
const cmd_type = convertBattleCmdType(cmd.battle_type);
for (const data of cmd.battle_datas) {
const battle_data: BattleCmdData = {
card_info: convertCardInfo(data.card_info, msg.player),
effect_description: data.effect_description,
direct_attackable: data.direct_attackable,
response: data.response,
};
battle_cmds.push({ cmd_type, data: battle_data });
}
}
if (msg.enable_m2) {
// response will be 2
battle_cmds.push({ cmd_type: BattleCmdType.ToM2 });
}
// TODO (ygo-agent): new models will support it
if (msg.enable_ep && !msg.enable_m2) {
// response will be 3
battle_cmds.push({ cmd_type: BattleCmdType.ToEp });
}
return {
msg_type: "select_battlecmd",
battle_cmds,
};
}
function convertMsgSelectUnselectCard(
msg: GM.MsgSelectUnselectCard,
): MsgSelectUnselectCard {
return {
msg_type: "select_unselect_card",
// response is -1 for finish
finishable: msg.finishable,
cancelable: msg.cancelable,
min: msg.min,
max: msg.max,
selected_cards: msg.selected_cards.map((c) => ({
location: convertCardLocation(c.location, msg.player),
response: c.response,
})),
selectable_cards: msg.selectable_cards.map((c) => ({
location: convertCardLocation(c.location, msg.player),
response: c.response,
})),
};
}
function convertMsgSelectOption(msg: GM.MsgSelectOption): MsgSelectOption {
return {
msg_type: "select_option",
options: msg.options.map((o) => ({
code: o.code,
response: o.response,
})),
};
}
// TODO (ygo-agent): SelectDisfield is different from SelectPlace
function convertMsgSelectPlace(msg: GM.MsgSelectPlace): MsgSelectPlace {
return {
msg_type: "select_place",
count: msg.count,
places: msg.places.map((p) => ({
// NOTICE: the response is the index of the place in the places array
controller: convertController(p.controller, msg.player),
location: cardZoneToLocation(p.zone),
sequence: p.sequence,
})),
};
}
function convertMsgAnnounceAttrib(msg: GM.MsgAnnounce): MsgAnnounceAttrib {
return {
msg_type: "announce_attrib",
count: msg.min,
// from api/ocgcore/ocgAdapter/stoc/stocGameMsg/announceAttrib.ts
attributes: msg.options.map((a) => ({
attribute: numberToAttribute(1 << a.code),
response: a.response,
})),
};
}
function convertMsgAnnounceNumber(msg: GM.MsgAnnounce): MsgAnnounceNumber {
return {
msg_type: "announce_number",
count: msg.min,
numbers: msg.options.map((o) => ({
number: o.code,
response: o.response,
})),
};
}
export function convertActionMsg(msg: ygopro.StocGameMessage): ActionMsg {
switch (msg.gameMsg) {
case "select_card":
return {
data: convertMsgSelectCard(msg.select_card),
};
case "select_tribute":
return { data: convertMsgSelectTribute(msg.select_tribute) };
case "select_sum":
return { data: convertMsgSelectSum(msg.select_sum) };
case "select_idle_cmd":
return { data: convertMsgSelectIdleCmd(msg.select_idle_cmd) };
case "select_chain":
return { data: convertMsgSelectChain(msg.select_chain) };
case "select_position":
return { data: convertMsgSelectPosition(msg.select_position) };
case "select_effect_yn":
return { data: convertMsgSelectEffectYn(msg.select_effect_yn) };
case "select_yes_no":
return { data: convertMsgSelectYesNo(msg.select_yes_no) };
case "select_battle_cmd":
return { data: convertMsgSelectBattleCmd(msg.select_battle_cmd) };
case "select_unselect_card":
return { data: convertMsgSelectUnselectCard(msg.select_unselect_card) };
case "select_option":
return { data: convertMsgSelectOption(msg.select_option) };
case "select_place":
return { data: convertMsgSelectPlace(msg.select_place) };
case "announce": {
const announce = msg.announce;
if (announce.announce_type === GM.MsgAnnounce.AnnounceType.Attribute) {
return {
data: convertMsgAnnounceAttrib(announce),
};
} else if (
announce.announce_type === GM.MsgAnnounce.AnnounceType.Number
) {
return {
data: convertMsgAnnounceNumber(announce),
};
} else {
throw new Error(`Unsupported announce type: ${announce.announce_type}`);
}
}
default:
throw new Error(`Unsupported message type: ${msg}`);
}
}
export function convertPositionResponse(response: number): ygopro.CardPosition {
switch (response) {
case FACEUP_ATTACK:
return ygopro.CardPosition.FACEUP_ATTACK;
case FACEDOWN_ATTACK:
return ygopro.CardPosition.FACEDOWN_ATTACK;
case FACEUP_DEFENSE:
return ygopro.CardPosition.FACEUP_DEFENSE;
case FACEDOWN_DEFENSE:
return ygopro.CardPosition.FACEDOWN_DEFENSE;
default:
throw new Error(`Invalid position response: ${response}`);
}
}
export function agentHeader(): Headers {
const myHeaders = new Headers();
myHeaders.append("User-Agent", "Apifox/1.0.0 (https://apifox.com)");
return myHeaders;
}
......@@ -3,31 +3,38 @@ import PhaseType = ygopro.StocGameMessage.MsgNewPhase.PhaseType;
import { CardMeta } from "@/api";
//! 一些Neos中基础的数据结构
// Position
export const FACEUP_ATTACK = 0x1;
export const FACEDOWN_ATTACK = 0x2;
export const FACEUP_DEFENSE = 0x4;
export const FACEDOWN_DEFENSE = 0x8;
// 类型
const TYPE_MONSTER = 0x1; //
const TYPE_SPELL = 0x2; //
const TYPE_TRAP = 0x4; //
const TYPE_NORMAL = 0x10; //
const TYPE_EFFECT = 0x20; //
const TYPE_FUSION = 0x40; //
const TYPE_RITUAL = 0x80; //
const TYPE_TRAPMONSTER = 0x100; //
const TYPE_SPIRIT = 0x200; //
const TYPE_UNION = 0x400; //
const TYPE_DUAL = 0x800; //
const TYPE_TUNER = 0x1000; //
const TYPE_SYNCHRO = 0x2000; //
export const TYPE_MONSTER = 0x1; //
export const TYPE_SPELL = 0x2; //
export const TYPE_TRAP = 0x4; //
export const TYPE_NORMAL = 0x10; //
export const TYPE_EFFECT = 0x20; //
export const TYPE_FUSION = 0x40; //
export const TYPE_RITUAL = 0x80; //
export const TYPE_TRAPMONSTER = 0x100; //
export const TYPE_SPIRIT = 0x200; //
export const TYPE_UNION = 0x400; //
export const TYPE_DUAL = 0x800; //
export const TYPE_TUNER = 0x1000; //
export const TYPE_SYNCHRO = 0x2000; //
export const TYPE_TOKEN = 0x4000; //
const TYPE_QUICKPLAY = 0x10000; //
const TYPE_CONTINUOUS = 0x20000; //
const TYPE_EQUIP = 0x40000; //
const TYPE_FIELD = 0x80000; //
const TYPE_COUNTER = 0x100000; //
const TYPE_FLIP = 0x200000; //
const TYPE_TOON = 0x400000; //
const TYPE_XYZ = 0x800000; //
const TYPE_PENDULUM = 0x1000000; //
const TYPE_SPSUMMON = 0x2000000; //
export const TYPE_QUICKPLAY = 0x10000; //
export const TYPE_CONTINUOUS = 0x20000; //
export const TYPE_EQUIP = 0x40000; //
export const TYPE_FIELD = 0x80000; //
export const TYPE_COUNTER = 0x100000; //
export const TYPE_FLIP = 0x200000; //
export const TYPE_TOON = 0x400000; //
export const TYPE_XYZ = 0x800000; //
export const TYPE_PENDULUM = 0x1000000; //
export const TYPE_SPSUMMON = 0x2000000; //
export const TYPE_LINK = 0x4000000; //
/*
......@@ -147,13 +154,13 @@ export function isPendulumMonster(typeCode: number): boolean {
// 属性
// const ATTRIBUTE_ALL = 0x7f; //
const ATTRIBUTE_EARTH = 0x01; //
const ATTRIBUTE_WATER = 0x02; //
const ATTRIBUTE_FIRE = 0x04; //
const ATTRIBUTE_WIND = 0x08; //
const ATTRIBUTE_LIGHT = 0x10; //
const ATTRIBUTE_DARK = 0x20; //
const ATTRIBUTE_DEVINE = 0x40; //
export const ATTRIBUTE_EARTH = 0x01; //
export const ATTRIBUTE_WATER = 0x02; //
export const ATTRIBUTE_FIRE = 0x04; //
export const ATTRIBUTE_WIND = 0x08; //
export const ATTRIBUTE_LIGHT = 0x10; //
export const ATTRIBUTE_DARK = 0x20; //
export const ATTRIBUTE_DEVINE = 0x40; //
export const Attribute2StringCodeMap: Map<number, number> = new Map([
[ATTRIBUTE_EARTH, 1010],
......@@ -166,31 +173,31 @@ export const Attribute2StringCodeMap: Map<number, number> = new Map([
]);
// 种族
const RACE_WARRIOR = 0x1; //
const RACE_SPELLCASTER = 0x2; //
const RACE_FAIRY = 0x4; //
const RACE_FIEND = 0x8; //
const RACE_ZOMBIE = 0x10; //
const RACE_MACHINE = 0x20; //
const RACE_AQUA = 0x40; //
const RACE_PYRO = 0x80; //
const RACE_ROCK = 0x100; //
const RACE_WINDBEAST = 0x200; //
const RACE_PLANT = 0x400; //
const RACE_INSECT = 0x800; //
const RACE_THUNDER = 0x1000; //
const RACE_DRAGON = 0x2000; //
const RACE_BEAST = 0x4000; //
const RACE_BEASTWARRIOR = 0x8000; //
const RACE_DINOSAUR = 0x10000; //
const RACE_FISH = 0x20000; //
const RACE_SEASERPENT = 0x40000; //
const RACE_REPTILE = 0x80000; //
const RACE_PSYCHO = 0x100000; //
const RACE_DEVINE = 0x200000; //
const RACE_CREATORGOD = 0x400000; //
const RACE_WYRM = 0x800000; //
const RACE_CYBERSE = 0x1000000; //
export const RACE_WARRIOR = 0x1; //
export const RACE_SPELLCASTER = 0x2; //
export const RACE_FAIRY = 0x4; //
export const RACE_FIEND = 0x8; //
export const RACE_ZOMBIE = 0x10; //
export const RACE_MACHINE = 0x20; //
export const RACE_AQUA = 0x40; //
export const RACE_PYRO = 0x80; //
export const RACE_ROCK = 0x100; //
export const RACE_WINDBEAST = 0x200; //
export const RACE_PLANT = 0x400; //
export const RACE_INSECT = 0x800; //
export const RACE_THUNDER = 0x1000; //
export const RACE_DRAGON = 0x2000; //
export const RACE_BEAST = 0x4000; //
export const RACE_BEASTWARRIOR = 0x8000; //
export const RACE_DINOSAUR = 0x10000; //
export const RACE_FISH = 0x20000; //
export const RACE_SEASERPENT = 0x40000; //
export const RACE_REPTILE = 0x80000; //
export const RACE_PSYCHO = 0x100000; //
export const RACE_DEVINE = 0x200000; //
export const RACE_CREATORGOD = 0x400000; //
export const RACE_WYRM = 0x800000; //
export const RACE_CYBERSE = 0x1000000; //
export const Race2StringCodeMap: Map<number, number> = new Map([
[RACE_WARRIOR, 1020],
......
import { WebSocketStream } from "@/infra";
import {
cardStore,
chatStore,
matStore,
placeStore,
roomStore,
} from "@/stores";
import { CONTAINERS } from ".";
import { Context } from "./context";
import { Container } from "./impl";
const UI_KEY = "NEOS_UI";
export function initUIContainer(conn: WebSocketStream) {
const context = new Context({
matStore,
cardStore,
placeStore,
roomStore,
chatStore,
});
const container = new Container(context, conn);
CONTAINERS.set(UI_KEY, container);
}
export function getUIContainer(): Container {
const container = CONTAINERS.get(UI_KEY);
if (container) {
return container;
} else {
throw Error("UI Container not initialized !!");
}
}
// Context of a Duel, containing datas and states
// that we need to interact with server and player
import {
CardStore,
ChatStore,
MatStore,
PlaceStore,
RoomStore,
SideStore,
} from "@/stores";
interface ContextInitInfo {
matStore?: MatStore;
cardStore?: CardStore;
placeStore?: PlaceStore;
roomStore?: RoomStore;
chatStore?: ChatStore;
sideStore?: SideStore;
}
export class Context {
public matStore: MatStore;
public cardStore: CardStore;
public placeStore: PlaceStore;
public roomStore: RoomStore;
public chatStore: ChatStore;
public sideStore: SideStore;
constructor();
constructor(initInfo: ContextInitInfo);
constructor(initInfo?: ContextInitInfo) {
const { matStore, cardStore, placeStore, roomStore, chatStore, sideStore } =
initInfo ?? {};
this.matStore = matStore ?? new MatStore();
this.cardStore = cardStore ?? new CardStore();
this.placeStore = placeStore ?? new PlaceStore();
this.roomStore = roomStore ?? new RoomStore();
this.chatStore = chatStore ?? new ChatStore();
this.sideStore = sideStore ?? new SideStore();
}
}
import { WebSocketStream } from "@/infra";
import { Context } from "./context";
export class Container {
public context: Context;
public conn: WebSocketStream;
// ref: https://yugioh.fandom.com/wiki/Kuriboh
private enableKuriboh: boolean = false;
constructor(context: Context, conn: WebSocketStream) {
this.context = context;
this.conn = conn;
}
public setEnableKuriboh(value: boolean) {
this.enableKuriboh = value;
}
public getEnableKuriboh(): boolean {
return this.enableKuriboh;
}
}
import { Container } from "./impl";
export { Context } from "./context";
export { Container } from "./impl";
// Global collection of `Container`s
export const CONTAINERS: Map<string, Container> = new Map();
......@@ -6,79 +6,36 @@
* */
import { WebSocketStream } from "@/infra";
import handleSocketMessage from "../service/onSocketMessage";
import handleSocketOpen from "../service/onSocketOpen";
export enum socketCmd {
// 建立长连接
CONNECT,
// 断开长连接
DISCONNECT,
// 通过长连接发送数据
SEND,
// FIXME: 应该有个返回值,告诉业务方本次请求的结果。比如建立长连接失败。
export function initSocket(initInfo: {
ip: string;
player: string;
passWd: string;
}): WebSocketStream {
const { ip, player, passWd } = initInfo;
return new WebSocketStream(ip, (conn, _event) =>
handleSocketOpen(conn, ip, player, passWd),
);
}
export interface socketAction {
cmd: socketCmd;
// 创建长连接需要业务方传入的数据
initInfo?: {
ip: string;
player: string;
passWd: string;
};
isReplay?: boolean; // 是否是回放模式
replayInfo?: {
Url: string; // 提供回放服务的地址
data: ArrayBuffer; // 回放数据
};
// 通过长连接发送的数据
payload?: Uint8Array;
export function initReplaySocket(replayInfo: {
url: string; // 提供回放服务的地址
data: ArrayBuffer; // 回放数据
}): WebSocketStream {
const { url, data } = replayInfo;
return new WebSocketStream(url, (conn, _event) => {
console.info("replay websocket open.");
conn.binaryType = "arraybuffer";
conn.send(data);
});
}
let ws: WebSocketStream | null = null;
// FIXME: 应该有个返回值,告诉业务方本次请求的结果。比如建立长连接失败。
export default async function (action: socketAction) {
switch (action.cmd) {
case socketCmd.CONNECT: {
const { initInfo: info, isReplay, replayInfo } = action;
if (info) {
ws = new WebSocketStream(info.ip, (conn, _event) =>
handleSocketOpen(conn, info.ip, info.player, info.passWd),
);
await ws.execute(handleSocketMessage);
} else if (isReplay && replayInfo) {
ws = new WebSocketStream(replayInfo.Url, (conn, _event) => {
console.info("replay websocket open.");
conn.binaryType = "arraybuffer";
conn.send(replayInfo.data);
});
await ws.execute(handleSocketMessage);
}
break;
}
case socketCmd.DISCONNECT: {
if (ws) {
ws.close();
}
break;
}
case socketCmd.SEND: {
const payload = action.payload;
if (ws && payload) {
ws.ws.send(payload);
}
break;
}
default: {
console.log("Unhandled socket command: " + action.cmd);
export function sendSocketData(conn: WebSocketStream, payload: Uint8Array) {
conn.ws.send(payload);
}
break;
}
}
export function closeSocket(conn: WebSocketStream) {
conn.close();
}
// TODO: this middleware should be managed under `Container`, too.
import { isNil } from "lodash-es";
import { Database } from "sql.js";
......
// Ygo Agent with AI-Assisted function on Yu-Gi-Oh! Game
import {
CardMeta,
createDuel,
fetchCard,
PredictReq,
sendSelectBattleCmdResponse,
sendSelectEffectYnResponse,
sendSelectIdleCmdResponse,
sendSelectMultiResponse,
sendSelectOptionResponse,
sendSelectPlaceResponse,
sendSelectPositionResponse,
sendSelectSingleResponse,
sendSortCardResponse,
ygopro,
} from "@/api";
import { predictDuel } from "@/api/ygoAgent/predict";
import {
Global,
Input,
MsgSelectSum,
MultiSelectMsg,
} from "@/api/ygoAgent/schema";
import {
convertActionMsg,
convertCard,
convertDeckCard,
convertPhase,
convertPositionResponse,
parsePlayerFromMsg,
} from "@/api/ygoAgent/transaction";
import { Context } from "@/container";
import { WebSocketStream } from "@/infra";
import { argmax, computeSetDifference } from "../util";
const { DECK, HAND, MZONE, SZONE, GRAVE, REMOVED, EXTRA } = ygopro.CardZone;
export class YgoAgent {
private context?: Context;
private duelId: string = "";
private agentIndex: number = 0;
private prevActionIndex: number = 0;
private mainDeck: number[] = [];
private mainDeckCardMeta: Map<number, CardMeta> = new Map();
private disable: boolean = false;
public attachContext(context: Context) {
this.context = context;
}
public async init() {
if (this.context === undefined)
throw Error("No context beed attached yet!!");
const resp = await createDuel();
if (resp) {
const { duelId, index } = resp;
this.duelId = duelId;
this.agentIndex = index;
this.mainDeck = this.context.sideStore.getSideDeck().main;
this.mainDeckCardMeta = this.mainDeck.reduce((map, item) => {
if (!map.has(item)) {
map.set(item, fetchCard(item));
}
return map;
}, new Map());
} else {
throw Error("Failed to createDuel for agent");
}
}
// When the AI model met some cards which it has not met,
// it can not work any more so we need to disable it.
public setDisable(value: boolean) {
this.disable = value;
}
public getDisable(): boolean {
return this.disable;
}
private genAgentInput(msg: ygopro.StocGameMessage): Input {
if (this.context) {
const mat = this.context.matStore;
const cardStore = this.context.cardStore;
// TODO (ygo-agent): TZONE
const zones = [DECK, HAND, MZONE, SZONE, GRAVE, REMOVED, EXTRA];
const player = parsePlayerFromMsg(msg);
const opponent = 1 - player;
const cards = cardStore.inner
.filter(
(card) =>
zones.includes(card.location.zone) &&
!(
card.location.zone === DECK && card.location.controller === player
),
)
.map((card) => convertCard(card, player));
const cardCodesMe = cardStore.inner
.filter(
(card) =>
zones.includes(card.location.zone) &&
card.location.controller === player,
)
.map((card) => card.code);
const cardCodesMeDeck = computeSetDifference(this.mainDeck, cardCodesMe);
const mainDeckCardMeta = this.mainDeckCardMeta;
// TODO (ygo-agent): 临时方案,有很多边界情况未考虑
const deckCardsMe = cardCodesMeDeck.map((code) =>
convertDeckCard(mainDeckCardMeta.get(code)!),
);
const turnPlayer = mat.currentPlayer;
const global: Global = {
is_first: player === 0,
is_my_turn: turnPlayer === player,
my_lp: mat.initInfo.of(player).life,
op_lp: mat.initInfo.of(opponent).life,
phase: convertPhase(mat.phase.currentPhase),
turn: mat.turnCount,
};
const actionMsg = convertActionMsg(msg);
return {
global,
cards: deckCardsMe.concat(cards),
action_msg: actionMsg,
};
} else {
throw Error("No context been attached yet!! ");
}
}
private async sendRequest(req: PredictReq) {
if (this.context) {
const duelId = this.duelId;
const resp = await predictDuel(duelId, req);
if (resp !== undefined) {
this.agentIndex = resp.index;
} else {
throw new Error("Failed to get predict response");
}
// TODO: 下面的逻辑需要封装一下,因为:
// 1. 现在实现的功能是AI托管,UI上不需要感知AI的预测结果;
// 2. 后面如果需要实现AI辅助功能,UI上需要感知AI的预测结果,
// 所以需要单独提供接口能力。
const preds = resp.predict_results.action_preds;
const actionIdx = argmax(preds, (r) => r.prob);
this.prevActionIndex = actionIdx;
const pred = preds[actionIdx];
return pred;
} else {
throw Error("No context been attached yet!! ");
}
}
private preprocess(
conn: WebSocketStream,
msg: ygopro.StocGameMessage,
): boolean {
if (
msg.gameMsg === "select_option" &&
msg.select_option.options.length === 0
) {
sendSelectOptionResponse(conn, 0);
return true;
} else if (msg.gameMsg === "select_chain") {
const selectChain = msg.select_chain;
if (
selectChain.chains.length === 0 ||
(!selectChain.forced && selectChain.special_count === 0)
) {
sendSelectSingleResponse(conn, -1);
return true;
}
} else if (msg.gameMsg === "select_place" && msg.select_place.count !== 1) {
throw Error("Unhandled case during handling MSG_SELECT_PLACE");
} else if (msg.gameMsg === "sort_card") {
// TODO: AI modal currently not support MSG_SORT_CARD, return default order here
sendSortCardResponse(
conn,
msg.sort_card.options.map((option) => option.response),
);
}
return false;
}
// TODO: AI模型需要一直跟踪对局,所以即使玩家关掉AI也需要给模型服务器发请求,
// 只是不会回应ygopro服务器。后面需要将这个函数解耦出来。
public async sendAIPredictAsResponse(
conn: WebSocketStream,
msg: ygopro.StocGameMessage,
enableKuriboh: boolean,
) {
if (this.disable) return;
if (this.context === undefined)
throw Error("No context been attached yet!!");
// preprocess
if (this.preprocess(conn, msg)) return;
const input = this.genAgentInput(msg);
const msgName = input.action_msg.data.msg_type;
const multiSelectMsgs = ["select_card", "select_tribute", "select_sum"];
if (multiSelectMsgs.includes(msgName)) {
switch (msgName) {
case "select_tribute":
case "select_card": {
const msg_ = input.action_msg.data as MultiSelectMsg;
const selected = [];
const responses = [];
while (true) {
msg_.selected = selected;
const req = {
index: this.agentIndex,
input: input,
prev_action_idx: this.prevActionIndex,
};
const response = (await this.sendRequest(req)).response;
if (response !== -1) {
selected.push(this.prevActionIndex);
responses.push(response);
}
if (
(response === -1 || selected.length === msg_.max) &&
enableKuriboh
) {
sendSelectMultiResponse(conn, responses);
break;
}
}
break;
}
case "select_sum":
const msg_ = input.action_msg.data as MsgSelectSum;
const selected = [];
const responses = [];
for (const c of msg_.must_cards) {
responses.push(c.response);
}
while (true) {
msg_.selected = selected;
const req = {
index: this.agentIndex,
input: input,
prev_action_idx: this.prevActionIndex,
};
const pred = await this.sendRequest(req);
const idx = this.prevActionIndex;
selected.push(idx);
responses.push(pred.response);
if (pred.can_finish && enableKuriboh) {
sendSelectMultiResponse(conn, responses);
break;
}
}
break;
}
} else {
const req = {
index: this.agentIndex,
input: input,
prev_action_idx: this.prevActionIndex,
};
const response = (await this.sendRequest(req)).response;
if (enableKuriboh) {
switch (msgName) {
case "announce_attrib":
case "announce_number":
sendSelectOptionResponse(conn, response);
break;
case "select_battlecmd":
sendSelectBattleCmdResponse(conn, response);
break;
case "select_chain":
sendSelectSingleResponse(conn, response);
break;
case "select_yesno":
case "select_effectyn":
sendSelectEffectYnResponse(conn, response === 1);
break;
case "select_idlecmd":
sendSelectIdleCmdResponse(conn, response);
break;
case "select_option":
sendSelectOptionResponse(conn, response);
break;
case "select_position":
sendSelectPositionResponse(conn, convertPositionResponse(response));
break;
case "select_place": {
const place = msg.select_place.places[response];
sendSelectPlaceResponse(conn, {
controller: place.controller,
zone: place.zone,
sequence: place.sequence,
});
break;
}
case "select_unselect_card": {
if (response === -1) {
sendSelectSingleResponse(conn, -1);
} else {
sendSelectMultiResponse(conn, [response]);
}
break;
}
}
}
}
}
}
import { ygopro } from "@/api";
import { Container } from "@/container";
import { replayStore } from "@/stores";
import { showWaiting } from "@/ui/Duel/Message";
import { YgoAgent } from "./agent";
import onAnnounce from "./announce";
import onMsgAttack from "./attack";
import onMsgAttackDisable from "./attackDisable";
......@@ -68,19 +70,6 @@ const ActiveList = [
"select_battle_cmd",
"select_unselect_card",
"select_yes_no",
];
const ReplayIgnoreMsg = [
"select_idle_cmd",
"select_place",
"select_card",
"select_chain",
"select_effect_yn",
"select_position",
"select_option",
"select_battle_cmd",
"select_unselect_card",
"select_yes_no",
"select_tribute",
"select_counter",
"select_sum",
......@@ -90,20 +79,43 @@ const ReplayIgnoreMsg = [
];
export default async function handleGameMsg(
container: Container,
pb: ygopro.YgoStocMsg,
agent?: YgoAgent,
): Promise<void> {
const msg = pb.stoc_game_msg;
if (ActiveList.includes(msg.gameMsg)) {
showWaiting(false);
}
if (replayStore.isReplay && ReplayIgnoreMsg.includes(msg.gameMsg)) return;
if (replayStore.isReplay) return;
if (agent && !agent.getDisable()) {
console.info(`Handling msg: ${msg.gameMsg} with YgoAgent`);
const enableKuriboh = container.getEnableKuriboh();
try {
await agent.sendAIPredictAsResponse(container.conn, msg, enableKuriboh);
if (enableKuriboh) return;
} catch (e) {
console.error(`Erros occurs when handling msg ${msg.gameMsg}: ${e}`);
container.setEnableKuriboh(false);
// TODO: I18N
container.context.matStore.error = `AI模型监测到场上存在它没见过的卡片,
因此需要关掉AI辅助功能。\n
请耐心等待开发团队对模型进行优化,感谢!`;
agent.setDisable(true);
}
}
}
switch (msg.gameMsg) {
case "start": {
await onMsgStart(msg.start);
// We should init agent when the MSG_START reached.
if (agent) await agent.init();
break;
}
case "draw": {
......@@ -132,7 +144,7 @@ export default async function handleGameMsg(
break;
}
case "select_place": {
onMsgSelectPlace(msg.select_place);
onMsgSelectPlace(container, msg.select_place);
break;
}
......@@ -141,12 +153,12 @@ export default async function handleGameMsg(
break;
}
case "select_card": {
onMsgSelectCard(msg.select_card);
onMsgSelectCard(container, msg.select_card);
break;
}
case "select_chain": {
onMsgSelectChain(msg.select_chain);
onMsgSelectChain(container, msg.select_chain);
break;
}
......@@ -161,7 +173,7 @@ export default async function handleGameMsg(
break;
}
case "select_option": {
await onMsgSelectOption(msg.select_option);
await onMsgSelectOption(container, msg.select_option);
break;
}
......
......@@ -6,4 +6,5 @@ export default (newTurn: ygopro.StocGameMessage.MsgNewTurn) => {
playEffect(AudioActionType.SOUND_NEXT_TURN);
const player = newTurn.player;
matStore.currentPlayer = player;
matStore.turnCount = matStore.turnCount + 1;
};
......@@ -8,11 +8,12 @@ import {
import MsgSelectBattleCmd = ygopro.StocGameMessage.MsgSelectBattleCmd;
export default (selectBattleCmd: MsgSelectBattleCmd) => {
export default async (selectBattleCmd: MsgSelectBattleCmd) => {
const player = selectBattleCmd.player;
const cmds = selectBattleCmd.battle_cmds;
// 先清掉之前的互动性
// TODO: 确认这里在AI托管的模式下是否需要
cardStore.inner.forEach((card) => {
card.idleInteractivities = [];
});
......
import { sendSelectMultiResponse, ygopro } from "@/api";
import MsgSelectCard = ygopro.StocGameMessage.MsgSelectCard;
import { Container } from "@/container";
import { displaySelectActionsModal } from "@/ui/Duel/Message/SelectActionsModal";
import { fetchCheckCardMeta } from "../utils";
export default async (selectCard: MsgSelectCard) => {
export default async (container: Container, selectCard: MsgSelectCard) => {
const { cancelable, min, max, cards } = selectCard;
const conn = container.conn;
// TODO: handle release_param
if (!cancelable && cards.length === 1) {
// auto send
sendSelectMultiResponse([cards[0].response]);
sendSelectMultiResponse(conn, [cards[0].response]);
return;
}
......
import { sendSelectSingleResponse, ygopro } from "@/api";
import { Container } from "@/container";
import { ChainSetting, fetchSelectHintMeta, matStore } from "@/stores";
import { displaySelectActionsModal } from "@/ui/Duel/Message/SelectActionsModal";
import { fetchCheckCardMeta } from "../utils";
type MsgSelectChain = ygopro.StocGameMessage.MsgSelectChain;
export default async (selectChain: MsgSelectChain) => {
export default async (container: Container, selectChain: MsgSelectChain) => {
const conn = container.conn;
const spCount = selectChain.special_count;
const forced = selectChain.forced;
const _hint0 = selectChain.hint0;
......@@ -15,7 +17,7 @@ export default async (selectChain: MsgSelectChain) => {
if (chainSetting === ChainSetting.CHAIN_IGNORE) {
// 如果玩家配置了忽略连锁,直接回应后端并返回
sendSelectSingleResponse(-1);
sendSelectSingleResponse(conn, -1);
return;
}
......@@ -60,7 +62,7 @@ export default async (selectChain: MsgSelectChain) => {
switch (handle_flag) {
case 0: {
// 直接回答
sendSelectSingleResponse(-1);
sendSelectSingleResponse(conn, -1);
break;
}
......@@ -86,7 +88,7 @@ export default async (selectChain: MsgSelectChain) => {
}
case 4: {
// 有一张强制发动的卡,直接回应
sendSelectSingleResponse(chains[0].response);
sendSelectSingleResponse(conn, chains[0].response);
break;
}
......
......@@ -8,11 +8,12 @@ import {
import MsgSelectIdleCmd = ygopro.StocGameMessage.MsgSelectIdleCmd;
export default (selectIdleCmd: MsgSelectIdleCmd) => {
export default async (selectIdleCmd: MsgSelectIdleCmd) => {
const player = selectIdleCmd.player;
const cmds = selectIdleCmd.idle_cmds;
// 先清掉之前的互动性
// TODO: 确认这里是否需要在AI托管的时候调用
cardStore.inner.forEach((card) => {
card.idleInteractivities = [];
});
......
......@@ -5,16 +5,22 @@ import {
sendSelectOptionResponse,
type ygopro,
} from "@/api";
import { Container } from "@/container";
import { displayOptionModal } from "@/ui/Duel/Message";
export default async (selectOption: ygopro.StocGameMessage.MsgSelectOption) => {
export default async (
container: Container,
selectOption: ygopro.StocGameMessage.MsgSelectOption,
) => {
const conn = container.conn;
const options = selectOption.options;
if (options.length === 0) {
sendSelectOptionResponse(0);
sendSelectOptionResponse(conn, 0);
return;
}
if (options.length === 1) {
sendSelectOptionResponse(options[0].response);
sendSelectOptionResponse(conn, options[0].response);
return;
}
......
import { sendSelectPlaceResponse, ygopro } from "@/api";
import { Container } from "@/container";
import { InteractType, placeStore } from "@/stores";
type MsgSelectPlace = ygopro.StocGameMessage.MsgSelectPlace;
export default (selectPlace: MsgSelectPlace) => {
export default async (container: Container, selectPlace: MsgSelectPlace) => {
const conn = container.conn;
if (selectPlace.count !== 1) {
console.warn(`Unhandled case: ${selectPlace}`);
return;
......@@ -11,7 +13,7 @@ export default (selectPlace: MsgSelectPlace) => {
if (selectPlace.places.length === 1) {
const place = selectPlace.places[0];
sendSelectPlaceResponse({
sendSelectPlaceResponse(conn, {
controller: place.controller,
zone: place.zone,
sequence: place.sequence,
......
......@@ -5,10 +5,10 @@ import { fetchCheckCardMeta } from "../utils";
type MsgSelectTribute = ygopro.StocGameMessage.MsgSelectTribute;
export default async (selectTribute: MsgSelectTribute) => {
// TODO: 当玩家选择卡数大于`max`时,是否也合法?
const { selecteds, mustSelects, selectables } = await fetchCheckCardMeta(
selectTribute.selectable_cards,
);
// TODO: 当玩家选择卡数大于`max`时,是否也合法?
await displaySelectActionsModal({
overflow: true,
totalLevels: 0,
......
......@@ -6,14 +6,15 @@ import { fetchCheckCardMeta } from "../utils";
import { isAllOnField } from "./util";
type MsgSelectUnselectCard = ygopro.StocGameMessage.MsgSelectUnselectCard;
export default async ({
finishable,
cancelable,
min,
max,
selectable_cards: selectableCards,
selected_cards: selectedCards,
}: MsgSelectUnselectCard) => {
export default async (selectUnselectCards: MsgSelectUnselectCard) => {
const {
finishable,
cancelable,
min,
max,
selectable_cards: selectableCards,
selected_cards: selectedCards,
} = selectUnselectCards;
if (
isAllOnField(
selectableCards.concat(selectedCards).map((info) => info.location),
......
import { sendTimeConfirm, ygopro } from "@/api";
import { Container } from "@/container";
import { matStore } from "@/stores";
export default function handleTimeLimit(timeLimit: ygopro.StocTimeLimit) {
export default function handleTimeLimit(
container: Container,
timeLimit: ygopro.StocTimeLimit,
) {
matStore.timeLimits.set(timeLimit.player, timeLimit.left_time);
if (matStore.isMe(timeLimit.player)) {
sendTimeConfirm();
sendTimeConfirm(container.conn);
}
}
......@@ -8,3 +8,48 @@ export function isAllOnField(locations: ygopro.CardLocation[]): boolean {
return locations.find((location) => !isOnField(location)) === undefined;
}
export function computeSetDifference(set1: number[], set2: number[]): number[] {
const freq1 = new Map<number, number>();
const freq2 = new Map<number, number>();
for (const num of set1) {
freq1.set(num, (freq1.get(num) || 0) + 1);
}
for (const num of set2) {
freq2.set(num, (freq2.get(num) || 0) + 1);
}
for (const [num, count] of freq2) {
if (freq1.has(num)) {
freq1.set(num, freq1.get(num)! - count);
}
}
const difference: number[] = [];
for (const [num, count] of freq1) {
if (count > 0) {
difference.push(...Array(count).fill(num));
}
}
return difference;
}
export function argmax<T>(arr: T[], getValue: (item: T) => number): number {
if (arr.length === 0) {
throw new Error("Array is empty");
}
let maxIndex = 0;
let maxValue = getValue(arr[0]);
for (let i = 1; i < arr.length; i++) {
const currentValue = getValue(arr[i]);
if (currentValue > maxValue) {
maxValue = currentValue;
maxIndex = i;
}
}
return maxIndex;
}
import { Container } from "@/container";
import { YgoAgent } from "./duel/agent";
import handleSocketMessage from "./onSocketMessage";
export async function pollSocketLooper(container: Container) {
await container.conn.execute((event) =>
handleSocketMessage(container, event),
);
}
export async function pollSocketLooperWithAgent(container: Container) {
const agent = new YgoAgent();
agent.attachContext(container.context);
await container.conn.execute((event) =>
handleSocketMessage(container, event, agent),
);
}
......@@ -4,8 +4,10 @@
* */
import { adaptStoc } from "@/api/ocgcore/ocgAdapter/adapter";
import { YgoProPacket } from "@/api/ocgcore/ocgAdapter/packet";
import { Container } from "@/container";
import { replayStore } from "@/stores";
import { YgoAgent } from "./duel/agent";
import handleGameMsg from "./duel/gameMsg";
import handleTimeLimit from "./duel/timeLimit";
import handleDeckCount from "./mora/deckCount";
......@@ -32,12 +34,21 @@ import { handleWaitingSide } from "./side/waitingSide";
let animation: Promise<void> = Promise.resolve();
export default async function handleSocketMessage(e: MessageEvent) {
export default async function handleSocketMessage(
container: Container,
e: MessageEvent,
agent?: YgoAgent,
) {
// 确保按序执行
animation = animation.then(() => _handle(e));
animation = animation.then(() => _handle(container, e, agent));
}
async function _handle(e: MessageEvent) {
// FIXME: 下面的所有`handler`中访问`Store`的时候都应该通过`Container`进行访问
async function _handle(
container: Container,
e: MessageEvent,
agent?: YgoAgent,
) {
const packets = YgoProPacket.deserialize(e.data);
for (const packet of packets) {
......@@ -97,12 +108,12 @@ async function _handle(e: MessageEvent) {
// 如果不是回放模式,则记录回放数据
replayStore.record(packet);
}
await handleGameMsg(pb);
await handleGameMsg(container, pb, agent);
break;
}
case "stoc_time_limit": {
handleTimeLimit(pb.stoc_time_limit);
handleTimeLimit(container, pb.stoc_time_limit);
break;
}
case "stoc_error_msg": {
......
......@@ -24,7 +24,7 @@ export interface CardType {
};
}
class CardStore implements NeosStore {
export class CardStore implements NeosStore {
inner: CardType[] = [];
at(zone: ygopro.CardZone, controller: number): CardType[];
at(
......
......@@ -2,15 +2,13 @@ import { proxy } from "valtio";
import { type NeosStore } from "./shared";
export interface ChatState extends NeosStore {
sender: number;
message: string;
export class ChatStore implements NeosStore {
sender: number = -1;
message: string = "";
reset(): void {
this.message = "";
}
}
export const chatStore = proxy<ChatState>({
sender: -1,
message: "",
reset() {
chatStore.message = "";
},
});
export const chatStore = proxy<ChatStore>(new ChatStore());
......@@ -18,6 +18,7 @@ const getWhom = (controller: number): "me" | "op" =>
* 原本名字叫judgeSelf
*/
export const isMe = (controller: number): boolean => {
// FIXME: all of the `matStore` need to access with container
switch (matStore.selfType) {
case 1:
// 自己是先攻
......@@ -93,9 +94,11 @@ const initialState: Omit<MatState, "reset"> = {
duelEnd: false,
// methods
isMe,
turnCount: 0,
error: "",
};
class MatStore implements MatState, NeosStore {
export class MatStore implements MatState, NeosStore {
chains = initialState.chains;
chainSetting = initialState.chainSetting;
timeLimits = initialState.timeLimits;
......@@ -109,6 +112,9 @@ class MatStore implements MatState, NeosStore {
tossResult = initialState.tossResult;
selectUnselectInfo = initialState.selectUnselectInfo;
duelEnd = initialState.duelEnd;
turnCount = initialState.turnCount;
error = initialState.error;
// methods
isMe = initialState.isMe;
reset(): void {
......@@ -137,6 +143,8 @@ class MatStore implements MatState, NeosStore {
selectedList: [],
};
this.duelEnd = false;
this.turnCount = 0;
this.error = initialState.error;
}
}
......
......@@ -49,6 +49,9 @@ export interface MatState {
/** 根据自己的先后手判断是否是自己 */
isMe: (player: number) => boolean;
turnCount: number;
error: string;
}
export interface InitInfo {
......
......@@ -58,7 +58,7 @@ const initialState = {
},
};
class PlaceStore implements NeosStore {
export class PlaceStore implements NeosStore {
inner: {
[zone: number]: {
me: BlockState[];
......@@ -70,14 +70,15 @@ class PlaceStore implements NeosStore {
controller: number;
sequence: number;
}): BlockState | undefined {
return placeStore.inner[location.zone][
return this.inner[location.zone][
// FIXME: inject `matStore`
matStore.isMe(location.controller) ? "me" : "op"
][location.sequence];
}
clearAllInteractivity() {
(["me", "op"] as const).forEach((who) => {
([MZONE, SZONE] as const).forEach((where) => {
placeStore.inner[where][who].forEach(
this.inner[where][who].forEach(
(block) => (block.interactivity = undefined),
);
});
......@@ -87,7 +88,7 @@ class PlaceStore implements NeosStore {
const resetObj = cloneDeep(initialState);
Object.keys(resetObj).forEach((key) => {
// @ts-ignore
placeStore.inner[key] = resetObj[key];
this.inner[key] = resetObj[key];
});
}
}
......
......@@ -33,7 +33,7 @@ export enum RoomStage {
DUEL_START = 6, // 决斗开始
}
class RoomStore implements NeosStore {
export class RoomStore implements NeosStore {
joined: boolean = false; // 是否已经加入房间
players: (Player | undefined)[] = Array.from({ length: 4 }).map(
(_) => undefined,
......
......@@ -15,7 +15,7 @@ export enum SideStage {
WAITING = 8, // 观战者等待双方玩家
}
class SideStore implements NeosStore {
export class SideStore implements NeosStore {
stage: SideStage = SideStage.NONE;
// 因为在上一局可能会出现断线重连,
......
......@@ -4,11 +4,13 @@ import { useNavigate } from "react-router-dom";
import { useSnapshot } from "valtio";
import { sendSurrender } from "@/api";
import { getUIContainer } from "@/container/compat";
import { matStore } from "@/stores";
export const Alert = () => {
const matSnap = useSnapshot(matStore);
const unimplemented = matSnap.unimplemented;
const container = getUIContainer();
const navigate = useNavigate();
......@@ -24,7 +26,7 @@ export const Alert = () => {
banner
afterClose={() => {
// 发送投降信号
sendSurrender();
sendSurrender(container.conn);
navigate("/match");
}}
/>
......
......@@ -10,6 +10,7 @@ import {
sendSelectOptionResponse,
} from "@/api";
import { isDeclarable, isToken } from "@/common";
import { getUIContainer } from "@/container/compat";
import { emptySearchConditions } from "@/middleware/sqlite/fts";
import { NeosModal } from "../NeosModal";
......@@ -35,6 +36,7 @@ export const AnnounceModal: React.FC = () => {
const [searchWord, setSearchWord] = useState("");
const [cardList, setCardList] = useState<CardMeta[]>([]);
const [selected, setSelected] = useState<number | undefined>(undefined);
const container = getUIContainer();
const handleSearch = () => {
const result = searchCards({
......@@ -51,7 +53,7 @@ export const AnnounceModal: React.FC = () => {
};
const onSummit = () => {
if (selected !== undefined) {
sendSelectOptionResponse(selected);
sendSelectOptionResponse(container.conn, selected);
rs();
setSearchWord("");
setCardList([]);
......
......@@ -5,6 +5,7 @@ import React, { useEffect, useState } from "react";
import { proxy, useSnapshot } from "valtio";
import { fetchStrings, Region, sendSelectCounterResponse } from "@/api";
import { getUIContainer } from "@/container/compat";
import { YgoCard } from "@/ui/Shared";
import { NeosModal } from "../NeosModal";
......@@ -27,6 +28,7 @@ const defaultProps = {
const localStore = proxy<CheckCounterModalProps>(defaultProps);
export const CheckCounterModal = () => {
const container = getUIContainer();
const snapCheckCounterModal = useSnapshot(localStore);
const isOpen = snapCheckCounterModal.isOpen;
......@@ -46,7 +48,7 @@ export const CheckCounterModal = () => {
}, [options]);
const onFinish = () => {
sendSelectCounterResponse(selected);
sendSelectCounterResponse(container.conn, selected);
rs();
};
......
......@@ -18,6 +18,7 @@ export const HintNotification = () => {
const toss = snap.tossResult;
const handResults = snap.handResults;
const currentPhase = snap.phase.currentPhase;
const error = snap.error;
const [msgApi, msgContextHolder] = message.useMessage({
maxCount: NeosConfig.ui.hint.maxCount,
......@@ -61,6 +62,12 @@ export const HintNotification = () => {
}
}, [currentPhase]);
useEffect(() => {
if (error !== "") {
msgApi.error(error);
}
}, [error]);
return <>{msgContextHolder}</>;
};
......
......@@ -35,7 +35,7 @@ export const NeosModal: React.FC<ModalProps> = (props) => {
maskClosable={true}
onCancel={() => setMini(!mini)}
closeIcon={mini ? <UpOutlined /> : <MinusOutlined />}
bodyStyle={{ padding: "10px 0" }}
style={{ padding: "10px 0" }}
mask={!mini}
wrapClassName={classNames({ [styles.wrap]: mini })}
closable={true}
......
......@@ -12,6 +12,8 @@ import {
sendSelectIdleCmdResponse,
sendSelectOptionResponse,
} from "@/api";
import { Container } from "@/container";
import { getUIContainer } from "@/container/compat";
import { NeosModal } from "../NeosModal";
......@@ -29,6 +31,7 @@ const store = proxy(defaultStore);
const MAX_NUM_PER_PAGE = 4;
export const OptionModal = () => {
const container = getUIContainer();
const snap = useSnapshot(store);
const { title, isOpen, min, options } = snap;
// options可能太多,因此分页展示
......@@ -41,7 +44,7 @@ export const OptionModal = () => {
const responses = selecteds.flat();
if (responses.length > 0) {
const response = responses.reduce((res, current) => res | current, 0); // 多个选择求或
sendSelectOptionResponse(response);
sendSelectOptionResponse(container.conn, response);
rs();
}
};
......@@ -132,6 +135,7 @@ export const displayOptionModal = async (
};
export const handleEffectActivation = async (
container: Container,
meta: CardMeta,
effectInteractivies: {
desc: string;
......@@ -144,7 +148,7 @@ export const handleEffectActivation = async (
}
if (effectInteractivies.length === 1) {
// 如果只有一个效果,点击直接触发
sendSelectIdleCmdResponse(effectInteractivies[0].response);
sendSelectIdleCmdResponse(container.conn, effectInteractivies[0].response);
} else {
// optionsModal
const options = effectInteractivies.map((effect) => {
......
......@@ -5,6 +5,7 @@ import React, { useState } from "react";
import { proxy, useSnapshot } from "valtio";
import { sendSelectPositionResponse, ygopro } from "@/api";
import { getUIContainer } from "@/container/compat";
import { NeosModal } from "../NeosModal";
......@@ -91,6 +92,7 @@ const translations: Translations = {
};
export const PositionModal = () => {
const container = getUIContainer();
const { isOpen, positions } = useSnapshot(localStore);
const [selected, setSelected] = useState<ygopro.CardPosition | undefined>(
undefined,
......@@ -105,7 +107,7 @@ export const PositionModal = () => {
disabled={selected === undefined}
onClick={() => {
if (selected !== undefined) {
sendSelectPositionResponse(selected);
sendSelectPositionResponse(container.conn, selected);
rs();
}
}}
......
import { INTERNAL_Snapshot as Snapshot, proxy, useSnapshot } from "valtio";
import { sendSelectMultiResponse, sendSelectSingleResponse } from "@/api";
import { getUIContainer } from "@/container/compat";
import {
type Option,
......@@ -32,25 +33,26 @@ const defaultProps: Omit<
const localStore = proxy(defaultProps);
export const SelectActionsModal: React.FC = () => {
const container = getUIContainer();
const snap = useSnapshot(localStore);
const onSubmit = (options: Snapshot<Option[]>) => {
const values = options.map((option) => option.response!);
if (localStore.isChain) {
sendSelectSingleResponse(values[0]);
sendSelectSingleResponse(container.conn, values[0]);
} else {
sendSelectMultiResponse(values);
sendSelectMultiResponse(container.conn, values);
}
rs();
};
const onFinish = () => {
sendSelectSingleResponse(FINISH_RESPONSE);
sendSelectSingleResponse(container.conn, FINISH_RESPONSE);
rs();
};
const onCancel = () => {
sendSelectSingleResponse(CANCEL_RESPONSE);
sendSelectSingleResponse(container.conn, CANCEL_RESPONSE);
rs();
};
......
......@@ -22,6 +22,7 @@ import { proxy, useSnapshot } from "valtio";
import { sendSortCardResponse } from "@/api";
import { CardMeta, getCardImgUrl } from "@/api/cards";
import { getUIContainer } from "@/container/compat";
import { NeosModal } from "../NeosModal";
......@@ -41,6 +42,7 @@ const defaultProps = {
const localStore = proxy<SortCardModalProps>(defaultProps);
export const SortCardModal = () => {
const container = getUIContainer();
const { isOpen, options } = useSnapshot(localStore);
const [items, setItems] = useState(options);
const sensors = useSensors(
......@@ -51,7 +53,10 @@ export const SortCardModal = () => {
);
const onFinish = () => {
sendSortCardResponse(items.map((item) => item.response));
sendSortCardResponse(
container.conn,
items.map((item) => item.response),
);
rs();
};
const onDragEnd = (event: DragEndEvent) => {
......
......@@ -3,6 +3,7 @@ import React from "react";
import { proxy, useSnapshot } from "valtio";
import { sendSelectEffectYnResponse } from "@/api";
import { getUIContainer } from "@/container/compat";
import { matStore } from "@/stores";
import { NeosModal } from "../NeosModal";
......@@ -16,6 +17,7 @@ const defaultProps = { isOpen: false };
const localStore = proxy<YesNoModalProps>(defaultProps);
export const YesNoModal: React.FC = () => {
const container = getUIContainer();
const { isOpen, msg } = useSnapshot(localStore);
const hint = useSnapshot(matStore.hint);
......@@ -31,7 +33,7 @@ export const YesNoModal: React.FC = () => {
<>
<Button
onClick={() => {
sendSelectEffectYnResponse(false);
sendSelectEffectYnResponse(container.conn, false);
rs();
}}
>
......@@ -40,7 +42,7 @@ export const YesNoModal: React.FC = () => {
<Button
type="primary"
onClick={() => {
sendSelectEffectYnResponse(true);
sendSelectEffectYnResponse(container.conn, true);
rs();
}}
>
......
......@@ -2,6 +2,8 @@ import classnames from "classnames";
import { type INTERNAL_Snapshot as Snapshot, useSnapshot } from "valtio";
import { sendSelectPlaceResponse, ygopro } from "@/api";
import { Container } from "@/container";
import { getUIContainer } from "@/container/compat";
import {
type BlockState,
cardStore,
......@@ -46,6 +48,7 @@ const BgExtraRow: React.FC<{
meSnap: Snapshot<BlockState[]>;
opSnap: Snapshot<BlockState[]>;
}> = ({ meSnap, opSnap }) => {
const container = getUIContainer();
return (
<div className={classnames(styles.row)}>
{Array.from({ length: 2 }).map((_, i) => (
......@@ -53,8 +56,8 @@ const BgExtraRow: React.FC<{
key={i}
className={styles.extra}
onClick={() => {
onBlockClick(meSnap[i].interactivity);
onBlockClick(opSnap[1 - i].interactivity);
onBlockClick(container, meSnap[i].interactivity);
onBlockClick(container, opSnap[1 - i].interactivity);
}}
disabled={meSnap[i].disabled || opSnap[1 - i].disabled}
highlight={!!meSnap[i].interactivity || !!opSnap[1 - i].interactivity}
......@@ -71,23 +74,27 @@ const BgRow: React.FC<{
szone?: boolean;
opponent?: boolean;
snap: Snapshot<BlockState[]>;
}> = ({ szone = false, opponent = false, snap }) => (
<div className={classnames(styles.row, { [styles.opponent]: opponent })}>
{Array.from({ length: 5 }).map((_, i) => (
<BgBlock
key={i}
className={classnames({ [styles.szone]: szone })}
onClick={() => onBlockClick(snap[i].interactivity)}
disabled={snap[i].disabled}
highlight={!!snap[i].interactivity}
chains={{ chains: snap[i].chainIndex }}
/>
))}
</div>
);
}> = ({ szone = false, opponent = false, snap }) => {
const container = getUIContainer();
return (
<div className={classnames(styles.row, { [styles.opponent]: opponent })}>
{Array.from({ length: 5 }).map((_, i) => (
<BgBlock
key={i}
className={classnames({ [styles.szone]: szone })}
onClick={() => onBlockClick(container, snap[i].interactivity)}
disabled={snap[i].disabled}
highlight={!!snap[i].interactivity}
chains={{ chains: snap[i].chainIndex }}
/>
))}
</div>
);
};
const BgOtherBlocks: React.FC<{ op?: boolean }> = ({ op }) => {
useSnapshot(cardStore);
const container = getUIContainer();
const meController = isMe(0) ? 0 : 1;
const judgeGlowing = (zone: ygopro.CardZone) =>
!!cardStore
......@@ -134,7 +141,7 @@ const BgOtherBlocks: React.FC<{ op?: boolean }> = ({ op }) => {
/>
<BgBlock
className={styles.field}
onClick={() => onBlockClick(field.interactivity)}
onClick={() => onBlockClick(container, field.interactivity)}
disabled={field.disabled}
highlight={!!field.interactivity}
chains={{ chains: field.chainIndex, op }}
......@@ -171,9 +178,12 @@ export const Bg: React.FC = () => {
);
};
const onBlockClick = (placeInteractivity: PlaceInteractivity) => {
const onBlockClick = (
container: Container,
placeInteractivity: PlaceInteractivity,
) => {
if (placeInteractivity) {
sendSelectPlaceResponse(placeInteractivity.response);
sendSelectPlaceResponse(container.conn, placeInteractivity.response);
cardStore.inner.forEach((card) => (card.idleInteractivities = []));
placeStore.clearAllInteractivity();
}
......
......@@ -11,6 +11,8 @@ import {
sendSelectIdleCmdResponse,
ygopro,
} from "@/api";
import { Container } from "@/container";
import { getUIContainer } from "@/container/compat";
import { eventbus, Task } from "@/infra";
import { cardStore, CardType, Interactivity, InteractType } from "@/stores";
import { showCardModal as displayCardModal } from "@/ui/Duel/Message/CardModal";
......@@ -40,6 +42,7 @@ import type { SpringApiProps } from "./springs/types";
const { HAND, GRAVE, REMOVED, EXTRA, MZONE, SZONE, TZONE } = ygopro.CardZone;
export const Card: React.FC<{ idx: number }> = React.memo(({ idx }) => {
const container = getUIContainer();
const card = cardStore.inner[idx];
const snap = useSnapshot(card);
......@@ -161,7 +164,10 @@ export const Card: React.FC<{ idx: number }> = React.memo(({ idx }) => {
if (!isField) {
// 单卡: 直接召唤/特殊召唤/...
const card = cards[0];
sendSelectIdleCmdResponse(getNonEffectResponse(action, card));
sendSelectIdleCmdResponse(
container.conn,
getNonEffectResponse(action, card),
);
clearAllIdleInteractivities();
} else {
// 场地: 选择卡片
......@@ -174,7 +180,7 @@ export const Card: React.FC<{ idx: number }> = React.memo(({ idx }) => {
})),
});
if (option.length > 0) {
sendSelectIdleCmdResponse(option[0].response!);
sendSelectIdleCmdResponse(container.conn, option[0].response!);
clearAllIdleInteractivities();
}
}
......@@ -223,6 +229,7 @@ export const Card: React.FC<{ idx: number }> = React.memo(({ idx }) => {
}
// 选择发动哪个效果
handleEffectActivation(
container,
tmpCard.idleInteractivities
.filter(
({ interactType }) => interactType === InteractType.ACTIVATE,
......@@ -246,7 +253,7 @@ export const Card: React.FC<{ idx: number }> = React.memo(({ idx }) => {
const selectInfo = card.selectInfo;
if (selectInfo.selectable || selectInfo.selected) {
if (selectInfo.response !== undefined) {
sendSelectMultiResponse([selectInfo.response]);
sendSelectMultiResponse(container.conn, [selectInfo.response]);
clearSelectInfo();
} else {
console.error("card is selectable but the response is undefined!");
......@@ -358,13 +365,14 @@ type DropdownItem = NonNullable<MenuProps["items"]>[number] & {
};
const handleEffectActivation = (
container: Container,
effectInteractivies: Interactivy[],
meta?: CardMeta,
) => {
if (!effectInteractivies.length) return;
else if (effectInteractivies.length === 1) {
// 如果只有一个效果,点击直接触发
sendSelectIdleCmdResponse(effectInteractivies[0].response);
sendSelectIdleCmdResponse(container.conn, effectInteractivies[0].response);
} else {
// optionsModal
const options = effectInteractivies.map((effect) => {
......
......@@ -3,6 +3,8 @@ import {
CheckOutlined,
CloseCircleFilled,
MessageFilled,
RobotFilled,
RobotOutlined,
StepForwardFilled,
} from "@ant-design/icons";
import {
......@@ -33,6 +35,8 @@ import styles from "./index.module.scss";
import PhaseType = ygopro.StocGameMessage.MsgNewPhase.PhaseType;
import { useTranslation } from "react-i18next";
import { getUIContainer } from "@/container/compat";
import { clearAllIdleInteractivities, clearSelectInfo } from "../../utils";
import { openChatBox } from "../ChatBox";
......@@ -215,6 +219,7 @@ const initialPhaseBind: [
];
export const Menu = () => {
const container = getUIContainer();
const { t: i18n } = useTranslation("Menu");
const {
currentPlayer,
......@@ -226,6 +231,10 @@ export const Menu = () => {
[],
);
const [enableKuriboh, setEnableKuriboh] = useState(
container.getEnableKuriboh(),
);
useEffect(() => {
const endResponse = [
PhaseType.BATTLE_START,
......@@ -266,8 +275,9 @@ export const Menu = () => {
label,
disabled: disabled,
onClick: () => {
if (response === 2) sendSelectIdleCmdResponse(response);
else sendSelectBattleCmdResponse(response);
if (response === 2)
sendSelectIdleCmdResponse(container.conn, response);
else sendSelectBattleCmdResponse(container.conn, response);
clearAllIdleInteractivities();
},
icon: disabled ? <CheckOutlined /> : <ArrowRightOutlined />,
......@@ -299,12 +309,20 @@ export const Menu = () => {
{
label: i18n("Confirm"),
danger: true,
onClick: sendSurrender,
onClick: () => {
sendSurrender(container.conn);
},
},
].map((item, i) => ({ key: i, ...item }));
const globalDisable = !matStore.isMe(currentPlayer);
const switchAutoSelect = () => {
const newValue = !enableKuriboh;
setEnableKuriboh(newValue);
container.setEnableKuriboh(newValue);
};
return (
<div className={styles["menu-container"]}>
<SelectManager />
......@@ -331,6 +349,13 @@ export const Menu = () => {
type="text"
></Button>
</DropdownWithTitle>
<Tooltip title="AI">
<Button
icon={enableKuriboh ? <RobotFilled /> : <RobotOutlined />}
onClick={switchAutoSelect}
type="text"
></Button>
</Tooltip>
<Tooltip title={i18n("ChatRoom")}>
<Button
icon={<MessageFilled />}
......@@ -401,10 +426,11 @@ const ChainIcon: React.FC<{ chainSetting: ChainSetting }> = ({
};
const SelectManager: React.FC = () => {
const container = getUIContainer();
const { t: i18n } = useTranslation("Menu");
const { finishable, cancelable } = useSnapshot(matStore.selectUnselectInfo);
const onFinishOrCancel = () => {
sendSelectSingleResponse(FINISH_CANCEL_RESPONSE);
sendSelectSingleResponse(container.conn, FINISH_CANCEL_RESPONSE);
clearSelectInfo();
};
return (
......
......@@ -81,8 +81,8 @@
"Select": "请选择",
"Minimum": "最小值",
"Maximum": "最大值",
"Confirm": "确 定",
"Cancel": "取 消"
"Confirm": "确定",
"Cancel": "取消"
},
"CardDetails": {
"Level": "等级",
......@@ -167,7 +167,9 @@
"UltraPreemptiveServer": "超先行服",
"PlayerNickname": "玩家昵称",
"RoomPasswordOptional": "房间密码(可选)",
"JoinRoom": "加入房间"
"JoinRoom": "加入房间",
"EnableAIAssist": "启用AI辅助功能",
"BetaTest": "Beta测试"
},
"ReplayModal": {
"SelectReplay": "选择回放",
......
......@@ -15,4 +15,16 @@
.select {
margin: 0.25rem 0;
}
.ai-assist-container {
display: flex;
align-items: center;
gap: 1rem;
margin: 0.25rem 0;
span {
font-size: 1rem;
color: #d7e70b;
}
}
}
import { App, Button, Input, Modal } from "antd";
import { App, Button, Input, Modal, Switch } from "antd";
import React, { ChangeEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
......@@ -43,6 +43,7 @@ export const MatchModal: React.FC = ({}) => {
const [confirmLoading, setConfirmLoading] = useState(false);
const navigate = useNavigate();
const { t: i18n } = useTranslation("MatchModal");
const [enableKuriboh, setEnableKuriBoh] = useState(false);
const handlePlayerChange = (event: ChangeEvent<HTMLInputElement>) => {
setPlayer(event.target.value);
......@@ -60,6 +61,7 @@ export const MatchModal: React.FC = ({}) => {
player,
ip: genServerAddress(serverId),
passWd: passwd,
enableKuriboh,
});
};
......@@ -116,6 +118,14 @@ export const MatchModal: React.FC = ({}) => {
]}
onChange={handleServerChange}
/>
<div className={styles["ai-assist-container"]}>
<span>{i18n("EnableAIAssist")}</span>
<span style={{ color: "red" }}>{`(${i18n("BetaTest")}!!)`}</span>
<Switch
value={enableKuriboh}
onChange={(value) => setEnableKuriBoh(value)}
/>
</div>
<Input
className={styles.input}
type="text"
......
......@@ -101,7 +101,7 @@ export const WatchContent: React.FC = () => {
<Input
className={styles.input}
placeholder={i18n("SearchRoomByPlayerUsername")}
bordered={false}
variant="borderless"
suffix={<Button type="text" icon={<SearchOutlined />} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
......
......@@ -2,8 +2,13 @@ import rustInit from "rust-src";
import { initStrings, initSuperPrerelease } from "@/api";
import { useConfig } from "@/config";
import socketMiddleWare, { socketCmd } from "@/middleware/socket";
import { getUIContainer, initUIContainer } from "@/container/compat";
import { initReplaySocket, initSocket } from "@/middleware/socket";
import sqliteMiddleWare, { sqliteCmd } from "@/middleware/sqlite";
import {
pollSocketLooper,
pollSocketLooperWithAgent,
} from "@/service/executor";
const NeosConfig = useConfig();
......@@ -12,6 +17,7 @@ export const connectSrvpro = async (params: {
ip: string;
player: string;
passWd: string;
enableKuriboh?: boolean;
replay?: boolean;
replayData?: ArrayBuffer;
}) => {
......@@ -38,20 +44,32 @@ export const connectSrvpro = async (params: {
await initSuperPrerelease();
if (params.replay && params.replayData) {
// 连接回放websocket服务
socketMiddleWare({
cmd: socketCmd.CONNECT,
isReplay: true,
replayInfo: {
Url: NeosConfig.replayUrl,
data: params.replayData,
},
// connect to replay Server
const conn = initReplaySocket({
url: NeosConfig.replayUrl,
data: params.replayData,
});
// initialize the UI Container
initUIContainer(conn);
// execute the event looper
pollSocketLooper(getUIContainer());
} else {
// 通过socket中间件向ygopro服务端请求建立长连接
socketMiddleWare({
cmd: socketCmd.CONNECT,
initInfo: params,
});
// connect to the ygopro Server
const conn = initSocket(params);
// initialize the UI Contaner
initUIContainer(conn);
// execute the event looper
if (params.enableKuriboh) {
const container = getUIContainer();
container.setEnableKuriboh(true);
pollSocketLooperWithAgent(container);
} else {
pollSocketLooper(getUIContainer());
}
}
};
......@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
import { useSnapshot } from "valtio";
import { sendChat } from "@/api";
import { getUIContainer } from "@/container/compat";
import { chatStore, isMe, roomStore } from "@/stores";
interface ChatItem {
......@@ -14,6 +15,7 @@ interface ChatItem {
}
export const useChat = (isDuel: boolean = false) => {
const container = getUIContainer();
const [chatList, setChatList] = useState<ChatItem[]>([]);
const [input, setInput] = useState<string | undefined>(undefined);
const chat = useSnapshot(chatStore);
......@@ -31,7 +33,7 @@ export const useChat = (isDuel: boolean = false) => {
/** 发信息 */
const onSend = () => {
if (input !== undefined) {
sendChat(input);
sendChat(container.conn, input);
setInput("");
}
};
......
......@@ -3,11 +3,13 @@ import React from "react";
import { useSnapshot } from "valtio";
import { sendTpResult } from "@/api";
import { getUIContainer } from "@/container/compat";
import { SideStage, sideStore } from "@/stores";
import styles from "./TpModal.module.scss";
export const TpModal: React.FC = () => {
const container = getUIContainer();
const { stage } = useSnapshot(sideStore);
return (
......@@ -20,7 +22,7 @@ export const TpModal: React.FC = () => {
<div className={styles.container}>
<Button
onClick={() => {
sendTpResult(true);
sendTpResult(container.conn, true);
sideStore.stage = SideStage.TP_SELECTED;
}}
>
......@@ -28,7 +30,7 @@ export const TpModal: React.FC = () => {
</Button>
<Button
onClick={() => {
sendTpResult(false);
sendTpResult(container.conn, false);
sideStore.stage = SideStage.TP_SELECTED;
}}
>
......
......@@ -8,6 +8,7 @@ import { useSnapshot } from "valtio";
import { CardMeta, fetchCard, sendUpdateDeck } from "@/api";
import { isExtraDeckCard } from "@/common";
import { getUIContainer } from "@/container/compat";
import { AudioActionType, changeScene } from "@/infra/audio";
import { IDeck, roomStore, SideStage, sideStore } from "@/stores";
......@@ -24,6 +25,7 @@ export const loader: LoaderFunction = async () => {
};
export const Component: React.FC = () => {
const container = getUIContainer();
const { message } = App.useApp();
const initialDeck = sideStore.getSideDeck();
const { stage } = useSnapshot(sideStore);
......@@ -65,7 +67,7 @@ export const Component: React.FC = () => {
message.info("重置成功");
};
const onSummit = () => {
sendUpdateDeck(deck);
sendUpdateDeck(container.conn, deck);
sideStore.setSideDeck(deck);
};
......
......@@ -11,7 +11,6 @@ import {
sendUpdateDeck,
ygopro,
} from "@/api";
import socketMiddleWare, { socketCmd } from "@/middleware/socket";
import PlayerState = ygopro.StocHsPlayerChange.State;
import SelfType = ygopro.StocTypeChange.SelfType;
import { App, Avatar, Button, Skeleton, Space } from "antd";
......@@ -22,7 +21,9 @@ import { LoaderFunction, useNavigate } from "react-router-dom";
import { useSnapshot } from "valtio";
import { useConfig } from "@/config";
import { getUIContainer } from "@/container/compat";
import { AudioActionType, changeScene } from "@/infra/audio";
import { closeSocket } from "@/middleware/socket";
import {
accountStore,
deckStore,
......@@ -48,6 +49,7 @@ export const loader: LoaderFunction = async () => {
};
export const Component: React.FC = () => {
const container = getUIContainer();
const { t: i18n } = useTranslation("WaitRoom");
const { message } = App.useApp();
const { user } = useSnapshot(accountStore);
......@@ -63,7 +65,7 @@ export const Component: React.FC = () => {
const navigate = useNavigate();
const updateDeck = (deck: IDeck) => {
sendUpdateDeck(deck);
sendUpdateDeck(container.conn, deck);
// 设置side里面的卡组
sideStore.setSideDeck(deck);
};
......@@ -71,7 +73,7 @@ export const Component: React.FC = () => {
const onDeckSelected = (deckName: string) => {
const newDeck = deckStore.get(deckName);
if (newDeck) {
sendHsNotReady();
sendHsNotReady(container.conn);
updateDeck(newDeck);
setDeck(newDeck);
} else {
......@@ -83,12 +85,12 @@ export const Component: React.FC = () => {
if (me?.state === PlayerState.NO_READY) {
if (deck) {
updateDeck(deck);
sendHsReady();
sendHsReady(container.conn);
} else {
message.error("请先选择卡组");
}
} else {
sendHsNotReady();
sendHsNotReady(container.conn);
}
};
......@@ -96,7 +98,7 @@ export const Component: React.FC = () => {
// 组件初始化时发一次更新卡组的包
//
// 否则娱乐匹配准备会有问题(原因不明)
if (deck) sendUpdateDeck(deck);
if (deck) sendUpdateDeck(container.conn, deck);
}, []);
useEffect(() => {
if (room.stage === RoomStage.DUEL_START) {
......@@ -184,11 +186,11 @@ export const Component: React.FC = () => {
</div>
<ActionButton
onMoraSelect={(mora) => {
sendHandResult(mora);
sendHandResult(container.conn, mora);
roomStore.stage = RoomStage.HAND_SELECTED;
}}
onTpSelect={(tp) => {
sendTpResult(tp === Tp.First);
sendTpResult(container.conn, tp === Tp.First);
roomStore.stage = RoomStage.TP_SELECTED;
}}
/>
......@@ -263,6 +265,7 @@ const MoraAvatar: React.FC<{ mora?: Mora }> = ({ mora }) => (
const Controller: React.FC<{ onDeckChange: (deckName: string) => void }> = ({
onDeckChange,
}) => {
const container = getUIContainer();
const { t: i18n } = useTranslation("WaitRoom");
const snapDeck = useSnapshot(deckStore);
const snapRoom = useSnapshot(roomStore);
......@@ -287,9 +290,9 @@ const Controller: React.FC<{ onDeckChange: (deckName: string) => void }> = ({
icon={<IconFont type="icon-record" size={18} />}
onClick={() => {
if (snapRoom.selfType !== SelfType.OBSERVER) {
sendHsToObserver();
sendHsToObserver(container.conn);
} else {
sendHsToDuelList();
sendHsToDuelList(container.conn);
}
}}
>
......@@ -326,8 +329,8 @@ const SideButtons: React.FC<{
</span>
}
onClick={() => {
// 断开websocket🔗
socketMiddleWare({ cmd: socketCmd.DISCONNECT });
// 断开websocket🔗
closeSocket(getUIContainer().conn);
// 重置stores
resetUniverse();
// 返回上一个路由
......@@ -355,6 +358,7 @@ const ActionButton: React.FC<{
onMoraSelect: (mora: Mora) => void;
onTpSelect: (tp: Tp) => void;
}> = ({ onMoraSelect, onTpSelect }) => {
const container = getUIContainer();
const room = useSnapshot(roomStore);
const { stage, isHost } = room;
const { t: i18n } = useTranslation("WaitRoom");
......@@ -371,7 +375,7 @@ const ActionButton: React.FC<{
room.getOpPlayer()?.state !== PlayerState.READY))
}
onClick={() => {
sendHsStart();
sendHsStart(container.conn);
}}
>
{stage === RoomStage.WAITING ? (
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment