파일 이식(0.31.1)

This commit is contained in:
2022-07-08 00:11:41 +09:00
parent db08cec2cb
commit 96d76a06ef
217 changed files with 29966 additions and 2 deletions
+64
View File
@@ -14,6 +14,9 @@ dist-ssr
# Editor directories and files
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.idea
.DS_Store
@@ -22,3 +25,64 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# 체섭 ignore
*.log
logs/*.txt
d_log/*.txt
d_log/*.zip
sess_*
*/logs/*.txt
*/logs/*/*.txt
*/logs/preserved
err.txt
hwe/.htaccess
d_shared
/d_pic
d_pic/*.jpg
d_pic/*.gif
d_pic/*.png
d_pic/*.webp
d_pic/uploaded_image
/dist_js
*/dist_misc/*
*/dist_js/*
*/dist_css/*
/dist_misc/*
/dist_js/*
/dist_css/*
d_setting/*.php
*/d_setting/*.php
*/d_setting/*.json
*/d_setting/templates/*.php
!*.orig.php
**/data/file_cache
**/old/*
test.*
/che
/kwe
/pwe
/nya
/pya
/twe
/hwe/data
/vendor
phpinfo.php
*.sqlite3
ingame/js/*
ingame/css/*
gateway/js/*
gateway/css/*
+1
View File
@@ -0,0 +1 @@
Deny from all
+1
View File
@@ -0,0 +1 @@
export {}
+14
View File
@@ -0,0 +1,14 @@
declare module 'vue-multiselect' {
// Type definitions for Vue-Multislect 2.1.0
// Definitions by: Akshay Jat https://github.com/akki-jat
import { VueConstructor } from 'vue';
declare class Multiselect extends VueConstructor { }
declare class multiselectMixin extends VueConstructor { }
declare class pointerMixin extends VueConstructor { }
export default Multiselect;
export { Multiselect, multiselectMixin, pointerMixin };
}
+57
View File
@@ -0,0 +1,57 @@
<template>
<div
v-for="(officer, _idx) in [chiefList[chiefLevel]]"
:key="_idx"
:class="[`chiefBox${chiefLevel}`, 'subRows']"
:style="style"
@click="$emit('click', this)"
>
<div
class="bg1 nameHeader"
:style="{
color: getNpcColor(officer?.npcType ?? 0),
textDecoration: isMe ? 'underline' : undefined,
}"
>
{{ officer ? officer?.name ?? "-" : "" }}
</div>
<div class="bg1 center row gx-0">
<div class="col">
{{ officer?.officerLevelText }}
</div>
<div class="col">
{{ officer ? (officer?.turnTime ?? " - ").slice(-5) : "" }}
</div>
</div>
<div
v-for="(turn, idx) in officer?.turn ?? []"
:key="idx"
class="tableCell align-self-center turn_pad"
:style="{
fontSize: mb_strwidth(turn.brief) > 28 ? `${28 / mb_strwidth(turn.brief)}em` : undefined,
}"
>
{{ turn.brief }}
</div>
</div>
</template>
<script lang="ts">
import { getNpcColor } from "@/common_legacy";
import { mb_strwidth } from "@/util/mb_strwidth";
import { defineComponent } from "vue";
import VueTypes from "vue-types";
export default defineComponent({
props: {
chiefLevel: VueTypes.integer.isRequired,
style: VueTypes.object.isRequired,
chiefList: VueTypes.object.isRequired,
isMe: VueTypes.bool.isRequired,
},
emits: ["click"],
methods: {
mb_strwidth,
getNpcColor,
},
});
</script>
+178
View File
@@ -0,0 +1,178 @@
<template>
<div style="position: relative">
<DragSelect
v-slot="{ selected }"
:class="['subRows', 'chiefCommand']"
:style="style"
:disabled="!props.officer || !isEditMode"
attribute="turnIdx"
@dragStart="dragStart()"
@dragDone="dragDone(...$event)"
>
<div class="bg1 center row gx-0" style="font-size: 1.2em">
<div class="col-5 align-self-center text-end">
{{ officer ? `${officer.officerLevelText} : ` : "" }}
</div>
<div
class="col-7 align-self-center"
:style="{
color: getNpcColor(officer?.npcType ?? 0),
}"
>
{{ officer?.name }}
</div>
</div>
<div v-for="vidx in maxTurn" :key="vidx" :turnIdx="vidx" class="row c-bg2 gx-0">
<div
:class="[
'col-2',
'time_pad',
'f_tnum',
(isDragToggle || isCopyButtonShown) && selected.has(vidx.toString()) ? 'inverted' : undefined,
]"
>
{{ turnTimes[vidx - 1] }}
</div>
<div v-if="!officer || !officer.turn || !(vidx - 1 in officer.turn)" class="center" />
<div
v-else
class="tableCell align-self-center col-10 center turn_pad"
:style="{
fontSize:
mb_strwidth(officer.turn[vidx - 1].brief) > 28
? `${28 / mb_strwidth(officer.turn[vidx - 1].brief)}em`
: undefined,
}"
>
{{ officer.turn[vidx - 1].brief }}
</div>
</div>
</DragSelect>
<BButton
ref="btnCopy"
:style="{
position: 'absolute',
display: isCopyButtonShown ? 'block' : 'none',
top: `${btnPos * 30 + 25}px`,
right: '10px',
}"
@blur="isCopyButtonShown = false"
@click="tryCopy()"
>
복사하기
</BButton>
</div>
</template>
<script setup lang="ts">
import { getNpcColor } from "@/common_legacy";
import { formatTime } from "@/util/formatTime";
import { mb_strwidth } from "@/util/mb_strwidth";
import { parseTime } from "@/util/parseTime";
import type { StoredActionsHelper } from "@/util/StoredActionsHelper";
import addMinutes from "date-fns/esm/addMinutes/index";
import { range } from "lodash";
import { inject, onMounted, ref, type PropType } from "vue";
import VueTypes from "vue-types";
import DragSelect from "@/components/DragSelect.vue";
import { BButton } from "bootstrap-vue-3";
import { QueryActionHelper } from "@/util/QueryActionHelper";
import type { ChiefResponse } from "@/defs/API/NationCommand";
const props = defineProps({
style: VueTypes.object.isRequired,
officer: {
type: Object as PropType<ChiefResponse["chiefList"][0]>,
default: undefined,
},
turnTerm: VueTypes.integer.isRequired,
maxTurn: VueTypes.integer.isRequired,
});
const btnPos = ref(0);
const btnCopy = ref<InstanceType<typeof BButton> | null>(null);
const storedActionsHelper = inject<StoredActionsHelper>("storedNationActionsHelper");
const isEditMode = storedActionsHelper?.isEditMode ?? ref(false);
const isDragToggle = ref(false);
const isCopyButtonShown = ref(false);
const queryActionHelper = new QueryActionHelper(props.maxTurn);
const selectedTurnList = queryActionHelper.selectedTurnList;
onMounted(() => {
if (props.officer === undefined) {
return;
}
queryActionHelper.reservedCommandList.value = props.officer.turn.map((rawTurn) => {
return {
...rawTurn,
time: "",
};
});
console.log(queryActionHelper.reservedCommandList.value);
});
function dragStart() {
isDragToggle.value = true;
}
function eqSet<T>(as: Set<T>, bs: Set<T>) {
if (as.size !== bs.size) return false;
for (var a of as) if (!bs.has(a)) return false;
return true;
}
function dragDone(...rawSelectedTurn: string[]) {
if (rawSelectedTurn.length === 0) {
return;
}
const newSelectedTurnList = new Set<number>();
let maxPos = -1;
for (const rawIdx of rawSelectedTurn) {
const idx = parseInt(rawIdx) - 1;
newSelectedTurnList.add(idx);
maxPos = Math.max(maxPos, idx);
}
btnPos.value = maxPos;
isDragToggle.value = false;
if (newSelectedTurnList.size == 1 && eqSet(selectedTurnList.value, newSelectedTurnList)) {
isCopyButtonShown.value = false;
return;
}
selectedTurnList.value = newSelectedTurnList;
isCopyButtonShown.value = true;
setTimeout(() => {
(btnCopy.value?.$el as HTMLButtonElement).focus();
}, 0);
}
function tryCopy() {
const actions = queryActionHelper.extractQueryActions();
isCopyButtonShown.value = false;
if (!storedActionsHelper) {
return;
}
storedActionsHelper.clipboard.value = [...actions];
}
const turnTimes = ref<string[]>([]);
if (!props.officer || !props.officer.turnTime) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const _ of range(props.maxTurn)) {
turnTimes.value.push("\xa0");
}
} else {
const baseTurnTime = parseTime(props.officer.turnTime);
for (const idx of range(props.officer.turn.length)) {
turnTimes.value.push(
formatTime(addMinutes(baseTurnTime, idx * props.turnTerm), props.turnTerm >= 5 ? "HH:mm" : "mm:ss")
);
}
}
</script>
+49
View File
@@ -0,0 +1,49 @@
import { SammoAPI } from './SammoAPI';
import type { GetConstResponse } from './defs/API/Global';
import type { CityID, CrewTypeID, GameCityDefault, GameConstType, GameUnitType, GameIActionCategory, GameIActionKey, GameIActionInfo } from './defs/GameObj';
export class GameConstStore {
public readonly gameConst: GameConstType;
public readonly gameUnitConst: Record<CrewTypeID, GameUnitType>;
public readonly cityConst: Record<CityID, GameCityDefault>;
public readonly cityConstMap: {
region: Record<number | string, string | number>;
level: Record<number | string, string | number>; //defs.CityLevelText
};
public readonly iActionInfo: Record<
GameIActionCategory,
Record<
GameIActionKey,
GameIActionInfo
>
>;
public readonly iActionKeyMap: Record<string, GameIActionCategory>;
constructor(response: GetConstResponse) {
const data = response.data;
this.gameConst = Object.freeze(data.gameConst);
this.gameUnitConst = Object.freeze(data.gameUnitConst);
this.cityConst = Object.freeze(data.cityConst);
this.cityConstMap = Object.freeze(data.cityConstMap);
this.iActionInfo = Object.freeze(data.iActionInfo);
this.iActionKeyMap = Object.freeze(data.iActionKeyMap);
}
}
let gameConstStore: GameConstStore | undefined = undefined;
export async function getGameConstStore(): Promise<GameConstStore> {
//TODO: LocalStorage Cache 조합도 생각해보기.
if (gameConstStore !== undefined) {
return gameConstStore;
}
try {
const result = await SammoAPI.Global.GetConst();
gameConstStore = new GameConstStore(result);
}
catch (e: unknown) {
console.error(`FATAL!: GameConst를 가져오지 못함: ${e}`);
throw e;
}
return gameConstStore;
}
+49
View File
@@ -0,0 +1,49 @@
<template>
<BContainer id="container" :toast="{ root: true }" class="bg0">
<TopBackBar type="close" :title="isResAuction ? '경매장' : '유니크 경매장'" :reloadable="true" @reload="tryReload">
<BButton @click="isResAuction = true">/</BButton>
<BButton @click="isResAuction = false">유니크</BButton>
</TopBackBar>
<AuctionResource v-if="isResAuction" ref="auctionResource"></AuctionResource>
<AuctionUniqueItem v-else ref="auctionUniqueItem"></AuctionUniqueItem>
<BottomBar type="close"></BottomBar>
</BContainer>
</template>
<script lang="ts">
/*declare const staticValues: {
serverID: string,
turnterm: number,
serverNick: string,
};*/
</script>
<script lang="ts" setup>
import { BButton, BContainer } from "bootstrap-vue-3";
import TopBackBar from "@/components/TopBackBar.vue";
import BottomBar from "./components/BottomBar.vue";
import AuctionResource from "@/components/AuctionResource.vue";
import AuctionUniqueItem from "@/components/AuctionUniqueItem.vue";
import { ref } from "vue";
const props = defineProps({
isResAuction: {
type: Boolean,
default: true,
},
});
const auctionResource = ref<InstanceType<typeof AuctionResource> | null>(null);
const auctionUniqueItem = ref<InstanceType<typeof AuctionUniqueItem> | null>(null);
const isResAuction = ref(props.isResAuction);
async function tryReload() {
console.log(auctionResource.value);
console.log(auctionUniqueItem.value);
if(isResAuction.value && auctionResource.value){
await auctionResource.value.refresh();
}
if(!isResAuction.value && auctionUniqueItem.value){
await auctionUniqueItem.value.refresh();
}
}
</script>
+192
View File
@@ -0,0 +1,192 @@
<template>
<div id="container">
<TopBackBar :title="title" />
<div id="newArticle" class="bg0">
<div class="newArticleHeader bg2 center"> 게시물 작성</div>
<div class="row gx-0">
<div class="col-2 col-md-1 articleTitle bg1 center">제목</div>
<div class="col-10 col-md-11">
<!-- eslint-disable-next-line vue/max-attributes-per-line -->
<input v-model="newArticle.title" class="titleInput" type="text" maxlength="250" placeholder="제목" />
</div>
</div>
<div class="row gx-0">
<div class="col-2 col-md-1 bg1 center">내용</div>
<div class="col-10 col-md-11">
<textarea
ref="newArticleTextForm"
v-model="newArticle.text"
class="contentInput autosize"
placeholder="내용"
@input="autoResizeTextarea"
/>
</div>
</div>
<div class="row">
<div class="col-8 col-md-10" />
<div class="col-4 col-md-2 d-grid">
<b-button id="submitArticle" @click="submitArticle"> 등록 </b-button>
</div>
</div>
</div>
<div id="board">
<template v-if="articles && articles.length">
<board-article
v-for="article in articles"
:key="article.no"
:article="article"
@submitComment="reloadArticles"
/>
</template>
<template v-else> 게시물이 없습니다. </template>
</div>
<BottomBar />
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, reactive, ref } from "vue";
import TopBackBar from "@/components/TopBackBar.vue";
import BottomBar from "@/components/BottomBar.vue";
import BoardArticle from "@/components/BoardArticle.vue";
import { convertFormData } from "@util/convertFormData";
import axios from "axios";
import type { InvalidResponse } from "@/defs";
import { autoResizeTextarea } from "@util/autoResizeTextarea";
import { unwrap } from "@util/unwrap";
export type BoardResponse = {
result: true;
articles: Record<number, BoardArticleItem>;
};
export type BoardArticleItem = {
no: number;
nation_no: number;
is_secret?: boolean;
date: string;
general_no: number;
author: string;
author_icon: string;
title: string;
text: string;
comment: BoardCommentItem[];
};
export type BoardCommentItem = {
no: number;
nation_no: number;
is_secret?: boolean;
date: string;
document_no: number;
general_no: number;
author: string;
text: string;
};
export default defineComponent({
name: "PageBoard",
components: {
TopBackBar,
BottomBar,
BoardArticle,
},
props: {
isSecretBoard: {
type: Boolean,
required: true,
},
},
setup(props) {
const newArticleTextForm = ref<HTMLInputElement>();
const articles = reactive<BoardArticleItem[]>([]);
const reloadArticles = async () => {
let boardResponse: BoardResponse;
try {
const response = await axios({
url: "j_board_get_articles.php",
responseType: "json",
method: "post",
data: convertFormData({
isSecret: props.isSecretBoard,
}),
});
const result: InvalidResponse | BoardResponse = response.data;
if (!result.result) {
throw result.reason;
}
boardResponse = result;
} catch (e) {
console.error(e);
alert(`에러: ${e}`);
return;
}
articles.length = 0;
articles.push(...Object.values(boardResponse.articles));
articles.reverse();
};
onMounted(async () => {
await reloadArticles();
});
return {
newArticleTextForm,
articles,
reloadArticles,
};
},
data() {
return {
title: this.isSecretBoard ? "기밀실" : "회의실",
newArticle: {
title: "",
text: "",
},
};
},
methods: {
autoResizeTextarea,
async submitArticle() {
const { title, text } = this.newArticle;
if (!title && !text) {
return;
}
let result: InvalidResponse;
try {
const response = await axios({
url: "j_board_article_add.php",
method: "post",
responseType: "json",
data: convertFormData({
isSecret: this.isSecretBoard,
title,
text,
}),
});
result = response.data;
if (!result.result) {
throw result.reason;
}
} catch (e) {
console.error(e);
alert(`실패했습니다. :${e}`);
return;
}
this.newArticle = { title: "", text: "" };
const newArticleTextForm = unwrap(this.newArticleTextForm);
newArticleTextForm.style.height = "auto";
await this.reloadArticles();
},
},
});
</script>
+90
View File
@@ -0,0 +1,90 @@
<template>
<BContainer v-if="asyncReady" id="container" :toast="{ root: true }">
<div class="card">
<h3 class="card-header">{{ serverName }} 현황</h3>
<MapViewer
v-if="cachedMap"
:server-nick="serverNick"
:serverID="serverID"
:map-name="unwrap(gameConstStore?.gameConst.mapName)"
:map-data="cachedMap"
:is-detail-map="true"
:city-position="cityPosition"
:format-city-info="formatCityInfoText"
:image-path="imagePath"
:disallow-click="true"
/>
<div v-if="cachedMap" class="card-body">
<template v-for="(item, idx) in cachedMap.history" :key="idx">
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="formatLog(item)" />
</template>
</div>
</div>
</BContainer>
</template>
<script lang="ts">
declare const staticValues: {
serverName: string;
serverNick: string;
serverID: string;
};
declare const getCityPosition: () => CityPositionMap;
declare const formatCityInfo: (city: MapCityParsedRaw) => MapCityParsed;
</script>
<script lang="ts" setup>
import { onMounted, provide, ref } from "vue";
import { BContainer } from "bootstrap-vue-3";
import { SammoAPI } from "./SammoAPI";
import { formatLog } from "./utilGame/formatLog";
import MapViewer, { type CityPositionMap, type MapCityParsedRaw, type MapCityParsed } from "./components/MapViewer.vue";
import { getGameConstStore, type GameConstStore } from "./GameConstStore";
import { unwrap } from "@/util/unwrap";
import type { CachedMapResult } from "./defs";
const serverName = staticValues.serverName;
const serverNick = staticValues.serverNick;
const serverID = staticValues.serverID;
const asyncReady = ref<boolean>(false);
const gameConstStore = ref<GameConstStore>();
provide("gameConstStore", gameConstStore);
const storeP = getGameConstStore().then((store) => {
gameConstStore.value = store;
});
void Promise.all([storeP]).then(() => {
asyncReady.value = true;
});
const cityPosition = getCityPosition();
const formatCityInfoText = formatCityInfo;
const imagePath = window.pathConfig.gameImage;
const cachedMap = ref<CachedMapResult>();
onMounted(async () => {
try {
cachedMap.value = await SammoAPI.Global.GetCachedMap();
} catch (e) {
console.error(e);
}
});
</script>
<style lang="scss">
@import "@/../scss/common/bootstrap5.scss";
@include media-1000px {
#container {
width: 700px;
}
}
@include media-500px {
#container {
width: 500px;
}
}
</style>
+209
View File
@@ -0,0 +1,209 @@
<template>
<div id="container" class="pageChiefCenter">
<TopBackBar title="사령부" reloadable @reload="reloadTable" />
<div v-if="chiefList !== undefined" id="mainTable" :class="`${targetIsMe ? 'targetIsMe' : 'targetIsNotMe'}`">
<template v-for="(chiefLevel, vidx) in [12, 10, 8, 6, 11, 9, 7, 5]" :key="chiefLevel">
<div v-if="vidx % 4 == 0" :class="['turnIdx', vidx == 0 && !targetIsMe ? undefined : 'only1000px']">
<div :class="['subRows', 'bg0']" :style="mainTableGridRows">
<div class="bg1">&nbsp;</div>
<div v-for="idx in maxChiefTurn" :key="idx" :class="[`turnIdxLeft`, 'align-self-center', 'center']">
{{ idx }}
</div>
</div>
</div>
<div
v-for="(officer, idx) in [chiefList[chiefLevel]]"
:key="idx"
:class="[`${viewTarget == chiefLevel ? '' : 'only1000px'}`]"
>
<TopItem
v-if="officerLevel != chiefLevel"
:style="mainTableGridRows"
:officer="officer"
:maxTurn="maxChiefTurn"
:turnTerm="turnTerm"
/>
<ChiefReservedCommand
v-else
:key="idx"
:targetIsMe="targetIsMe"
:year="year"
:month="month"
:turn="officer.turn"
:turnTerm="turnTerm"
:commandList="unwrap(commandList)"
:turnTime="officer.turnTime"
:maxTurn="maxChiefTurn"
:maxPushTurn="Math.floor(maxChiefTurn / 2)"
:date="date"
:officer="officer"
@raiseReload="reloadTable()"
/>
</div>
<div v-if="vidx % 4 == 3" :class="['turnIdx', vidx == 7 && !targetIsMe ? undefined : 'only1000px']">
<div :class="['subRows', 'bg0']" :style="mainTableGridRows">
<div class="bg1">&nbsp;</div>
<div v-for="idx in maxChiefTurn" :key="idx" :class="[`turnIdxRight`, 'align-self-center', 'center']">
{{ idx }}
</div>
</div>
</div>
</template>
</div>
</div>
<div v-if="chiefList" id="bottomChiefBox">
<div id="bottomChiefList" class="c-bg2">
<template v-for="(chiefLevel, vidx) in [12, 10, 8, 6, 11, 9, 7, 5]" :key="chiefLevel">
<div v-if="vidx % 4 == 0" class="turnIdx subRows bg0" :style="subTableGridRows">
<div class="bg1" style="grid-row: 1/3" />
<div v-for="idx in maxChiefTurn" :key="idx" :class="[`turnIdxLeft`]">
{{ idx }}
</div>
</div>
<BottomItem
:chiefLevel="chiefLevel"
:chiefList="chiefList"
:style="subTableGridRows"
:isMe="chiefLevel == officerLevel"
@click="viewTarget = chiefLevel"
/>
<div v-if="vidx % 4 == 3" class="turnIdx subRows bg0" :style="subTableGridRows">
<div class="bg1" style="grid-row: 1/3" />
<div v-for="idx in maxChiefTurn" :key="idx" :class="`turnIdxRight`">
{{ idx }}
</div>
</div>
</template>
</div>
</div>
<div id="bottomBar">
<BottomBar />
</div>
</template>
<script lang="ts">
declare const staticValues: {
serverNick: string;
mapName: string;
unitSet: string;
};
</script>
<script lang="ts" setup>
import "@scss/common/bootstrap5.scss";
import "@scss/game_bg.scss";
import "../../css/config.css";
import { computed, provide, reactive, ref, toRefs, watch } from "vue";
import ChiefReservedCommand from "@/components/ChiefReservedCommand.vue";
import TopBackBar from "@/components/TopBackBar.vue";
import BottomBar from "@/components/BottomBar.vue";
import VueTypes from "vue-types";
import { isString } from "lodash";
import { entriesWithType } from "./util/entriesWithType";
import TopItem from "@/ChiefCenter/TopItem.vue";
import BottomItem from "@/ChiefCenter/BottomItem.vue";
import type { OptionalFull } from "./defs";
import { SammoAPI } from "./SammoAPI";
import { unwrap } from "@/util/unwrap";
import { StoredActionsHelper } from "./util/StoredActionsHelper";
import type { ChiefResponse } from "./defs/API/NationCommand";
const props = defineProps({
maxChiefTurn: VueTypes.number.isRequired,
});
const tableObj = reactive<Omit<OptionalFull<ChiefResponse>, "result">>({
lastExecute: undefined,
year: undefined,
month: undefined,
turnTerm: undefined,
date: undefined,
chiefList: undefined,
isChief: undefined,
autorun_limit: undefined,
officerLevel: undefined,
commandList: undefined,
mapName: undefined,
unitSet: undefined,
});
const { year, month, turnTerm, date, chiefList, officerLevel, commandList } = toRefs(tableObj);
const viewTarget = ref<number | undefined>();
const targetIsMe = ref<boolean>(false);
watch(viewTarget, (val) => {
console.log("targetChange!", val, targetIsMe);
if (val === undefined) {
targetIsMe.value = false;
return;
}
if (tableObj.officerLevel === undefined) {
targetIsMe.value = false;
}
targetIsMe.value = val === tableObj.officerLevel;
});
async function reloadTable(): Promise<void> {
try {
const response = await SammoAPI.NationCommand.GetReservedCommand();
console.log(response);
for (const [key, value] of entriesWithType(response)) {
if (key === "result") {
continue;
}
if (key === "officerLevel") {
if (value < 5) {
tableObj.officerLevel = undefined;
} else {
tableObj.officerLevel = value as number;
}
continue;
}
//HACK:
tableObj[key as unknown as "year"] = value as unknown as undefined;
}
if (viewTarget.value === undefined) {
if (!tableObj.officerLevel) {
viewTarget.value = 12;
} else {
viewTarget.value = tableObj.officerLevel;
}
}
} catch (e) {
if (isString(e)) {
alert(e);
}
console.error(e);
return;
}
}
const mainTableGridRows = computed(() => {
return {
gridTemplateRows: `24px repeat(${props.maxChiefTurn}, 30px)`,
};
});
const subTableGridRows = computed(() => {
return {
gridTemplateRows: `36px repeat(${props.maxChiefTurn + 1}, 1fr)`,
};
});
void reloadTable();
const storedActionsHelper = new StoredActionsHelper(
staticValues.serverNick,
"nation",
staticValues.mapName,
staticValues.unitSet
);
provide("storedNationActionsHelper", storedActionsHelper);
</script>
<style lang="scss">
@import "@scss/chiefCenter.scss";
</style>
+299
View File
@@ -0,0 +1,299 @@
<template>
<!-- eslint-disable vue/no-v-html -->
<BContainer v-if="asyncReady" id="container" :toast="{ root: true }" class="pageGlobalDiplomacy">
<TopBackBar title="중원 정보"></TopBackBar>
<div class="diplomacy bg0">
<div class="s-border-tb center tb-title" style="background-color: blue">외교 현황</div>
<div class="diplomacy_area">
<table v-if="diplomacy" class="center" style="margin: auto; min-width: 400px">
<thead>
<tr>
<th></th>
<th
v-for="nation of diplomacy.nations"
:key="nation.nation"
class="thead-nation"
:style="{
color: isBrightColor(nation.color) ? '#000' : '#fff',
backgroundColor: nation.color,
}"
>
{{ nation.name }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="me of diplomacy.nations" :key="me.nation">
<th
class="tbody-nation"
:style="{
color: isBrightColor(me.color) ? '#000' : '#fff',
backgroundColor: me.color,
}"
>
{{ me.name }}
</th>
<template v-for="you of diplomacy.nations" :key="you.nation">
<td v-if="me.nation == you.nation" class="tbody-cell"></td>
<td
v-else-if="me.nation == diplomacy.myNationID || you.nation == diplomacy.myNationID"
class="tbody-cell"
style="background-color: #660000"
v-html="infomativeStateCharMap[diplomacy.diplomacyList[me.nation][you.nation]]"
/>
<td
v-else
class="tbody-cell"
v-html="neutralStateCharMap[diplomacy.diplomacyList[me.nation][you.nation]]"
/>
</template>
</tr>
</tbody>
<tfoot>
<tr>
<td :colspan="Object.keys(diplomacy.nations).length + 1" class="center">
불가침 :
<span style="color: limegreen">@</span>, 통상 : , 선포 : <span style="color: magenta"></span>, 교전 :
<span style="color: red"></span>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
<div v-if="diplomacy && diplomacy.conflict.length > 0" class="conflict-area gx-0 bg0">
<div class="s-border-tb center tb-title" style="background-color: magenta">분쟁 현황</div>
<div v-for="[cityID, conflictNations] of diplomacy.conflict" :key="cityID" class="row gx-0">
<div class="conflictCityName">{{ gameConstStore?.cityConst[cityID].name }}</div>
<div class="conflictNation col">
<div
v-for="[nation, percent] of Object.entries(conflictNations).map(
([nationID, percent])=>[nationMap.get(parseInt(nationID)),percent] as [SimpleNationObj, number]
)"
:key="nation.nation"
class="row gx-0"
>
<div
class="conflictNationName"
:style="{
color: isBrightColor(nation.color) ? '#000' : '#fff',
backgroundColor: nation.color,
flexBasis: '16ch',
paddingLeft: '1ch',
}"
>
{{ nation.name }}
</div>
<div
class="conflictNationPercent"
:style="{
flexBasis: '6ch',
textAlign: 'right',
paddingRight: '0.5ch',
}"
>
{{ percent.toLocaleString(undefined, { minimumFractionDigits: 1 }) }}%
</div>
<div class="col align-self-center">
<div
class="progress"
:style="{
width: `${percent}%`,
marginLeft: 0,
height: '1.2em',
backgroundColor: nation.color,
}"
></div>
</div>
</div>
</div>
</div>
</div>
<div class="map_area mx-0 bg0">
<div class="s-border-tb center tb-title" style="background-color: green">중원 지도</div>
<div class="row g-0">
<div class="map_position">
<MapViewer
v-if="map"
:server-nick="serverNick"
:serverID="serverID"
:map-name="unwrap(gameConstStore?.gameConst.mapName)"
:map-data="map"
:is-detail-map="true"
:city-position="cityPosition"
:format-city-info="formatCityInfoText"
:image-path="imagePath"
:disallow-click="true"
/>
</div>
<div class="nation_position"><SimpleNationList v-if="diplomacy" :nations="diplomacy.nations" /></div>
</div>
</div>
<BottomBar></BottomBar>
</BContainer>
</template>
<script lang="ts">
declare const staticValues: {
serverNick: string;
serverID: string;
};
declare const getCityPosition: () => CityPositionMap;
declare const formatCityInfo: (city: MapCityParsedRaw) => MapCityParsed;
</script>
<script lang="ts" setup>
import { onMounted, provide, ref, watch } from "vue";
import { BContainer, useToast } from "bootstrap-vue-3";
import TopBackBar from "@/components/TopBackBar.vue";
import BottomBar from "@/components/BottomBar.vue";
import { SammoAPI } from "./SammoAPI";
import SimpleNationList from "./components/SimpleNationList.vue";
import MapViewer, { type CityPositionMap, type MapCityParsedRaw, type MapCityParsed } from "./components/MapViewer.vue";
import { getGameConstStore, type GameConstStore } from "./GameConstStore";
import { unwrap } from "@/util/unwrap";
import type { SimpleNationObj, diplomacyState, MapResult } from "./defs";
import type { GetDiplomacyResponse } from "./defs/API/Global";
import { isString } from "lodash";
import { isBrightColor } from "@/util/isBrightColor";
const serverID = staticValues.serverID;
const serverNick = staticValues.serverNick;
const asyncReady = ref<boolean>(false);
const gameConstStore = ref<GameConstStore>();
provide("gameConstStore", gameConstStore);
const storeP = getGameConstStore().then((store) => {
gameConstStore.value = store;
});
void Promise.all([storeP]).then(() => {
asyncReady.value = true;
});
const infomativeStateCharMap: Record<diplomacyState, string> = {
0: '<span style="color:red;">★</span>',
1: '<span style="color:magenta;">▲</span>',
2: "ㆍ",
7: '<span style="color:green;">@</span>',
};
const neutralStateCharMap: Record<diplomacyState, string> = {
0: '<span style="color:red;">★</span>',
1: '<span style="color:magenta;">▲</span>',
2: "",
7: "에러",
};
const cityPosition = getCityPosition();
const formatCityInfoText = formatCityInfo;
const imagePath = window.pathConfig.gameImage;
const map = ref<MapResult>();
const diplomacy = ref<GetDiplomacyResponse>();
const nationMap = ref(new Map<number, SimpleNationObj>());
const toasts = unwrap(useToast());
onMounted(async () => {
try {
map.value = await SammoAPI.Global.GetMap({
neutralView: 0,
showMe: 1,
});
} catch (e) {
if (isString(e)) {
toasts.danger({
title: "에러",
body: e,
});
}
console.error(e);
}
});
watch(diplomacy, (diplomacy) => {
if (diplomacy === undefined) {
return;
}
const map = new Map<number, SimpleNationObj>();
for (const nation of diplomacy.nations) {
map.set(nation.nation, nation);
}
nationMap.value = map;
});
onMounted(async () => {
try {
diplomacy.value = await SammoAPI.Global.GetDiplomacy();
} catch (e) {
if (isString(e)) {
toasts.danger({
title: "에러",
body: e,
});
}
console.error(e);
}
});
</script>
<style lang="scss" scoped>
* {
font-size: 14px;
}
.diplomacy_area {
width: 100%;
overflow-x: auto;
}
.thead-nation {
writing-mode: vertical-rl;
text-orientation: mixed;
text-align: end;
padding-bottom: 1ch;
padding-top: 1ch;
padding-left: 0;
padding-right: 0;
min-width: 1ch;
max-width: 3ch;
font-weight: normal;
}
.tbody-nation {
text-align: end;
padding-right: 1ch;
padding-left: 1ch;
min-width: 10ch;
font-weight: normal;
}
.diplomacy {
margin-top: 1.5em;
}
.conflict-area {
margin-top: 1.5em;
}
.map_area {
margin-top: 1.5em;
}
.tb-title {
font-size: 1.2em;
}
.tbody-cell {
border-left: solid 1px gray;
border-top: solid 1px gray;
padding: 0;
}
.conflictCityName {
flex-basis: 16ch;
text-align: right;
padding-right: 1ch;
align-self: center;
}
</style>
+231
View File
@@ -0,0 +1,231 @@
<template>
<BContainer v-if="asyncReady" id="container" :toast="{ root: true }" class="bg0 pageHistory">
<TopBackBar title="연감" type="close">
<div>&nbsp;</div>
<!-- HACK: variant에 정상적인 값을 넣어야 해서...-->
<BDropdown class="optionMenu" right :variant="('sammo-base2' as 'primary')">
<template #button-content><i class="bi bi-gear-fill"></i>&nbsp;설정</template>
<BDropdownItem @click="isNationRankingBottom = !isNationRankingBottom"
>국가 순서 위치 변경(모바일 전용)</BDropdownItem
>
</BDropdown>
</TopBackBar>
<div class="center row mx-0 s-border-tb">
<div class="col-md-1 col-2 year-selector text-end align-self-center">연월 선택:</div>
<BButton class="col-md-1 col-2" @click="queryYearMonth = unwrap(queryYearMonth) - 1"> 이전달</BButton>
<div class="col-md-3 col-5 d-grid">
<BFormSelect v-model="queryYearMonth" :options="generateYearMonthList()" />
</div>
<BButton class="col-md-1 col-2" @click="queryYearMonth = unwrap(queryYearMonth) + 1">다음달 </BButton>
</div>
<div v-if="history" id="map_holder" :class="['row', 'gx-0', isNationRankingBottom ? 'isNationRankingBottom' : '']">
<div class="map_position">
<MapViewer
:server-nick="serverNick"
:serverID="queryServerID"
:map-name="mapName"
:map-data="history.map"
:is-detail-map="true"
:city-position="cityPosition"
:format-city-info="formatCityInfoText"
:image-path="imagePath"
:disallow-click="true"
/>
</div>
<div class="nation_position"><SimpleNationList :nations="history.nations" /></div>
<div class="world_history col-12">
<div class="bg1 center s-border-tb"><b>중원 정세</b></div>
<div class="content">
<template v-for="(item, idx) in history.global_history" :key="idx">
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="formatLog(item)" />
</template>
</div>
</div>
<div class="general_public_record col-12">
<div class="bg1 center s-border-tb"><b>장수 동향</b></div>
<div class="content">
<template v-for="(item, idx) in history.global_action" :key="idx">
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="formatLog(item)" />
</template>
</div>
</div>
</div>
<BottomBar type="close"></BottomBar>
</BContainer>
</template>
<script lang="ts">
declare const staticValues: {
firstYearMonth: number;
lastYearMonth: number;
currentYearMonth: number;
serverNick: string;
serverID: string;
mapName: string;
};
declare const query: {
yearMonth: number | null;
serverID: string;
};
declare const getCityPosition: () => CityPositionMap;
declare const formatCityInfo: (city: MapCityParsedRaw) => MapCityParsed;
</script>
<script lang="ts" setup>
import { onMounted, provide, ref, watch } from "vue";
import { BContainer, BButton, BFormSelect, BDropdown, BDropdownItem } from "bootstrap-vue-3";
import TopBackBar from "@/components/TopBackBar.vue";
import BottomBar from "@/components/BottomBar.vue";
import type { HistoryObj } from "./defs/API/Global";
import { SammoAPI } from "./SammoAPI";
import { joinYearMonth } from "./util/joinYearMonth";
import { parseYearMonth } from "./util/parseYearMonth";
import { formatLog } from "./utilGame/formatLog";
import SimpleNationList from "./components/SimpleNationList.vue";
import MapViewer, { type CityPositionMap, type MapCityParsedRaw, type MapCityParsed } from "./components/MapViewer.vue";
import { getGameConstStore, type GameConstStore } from "./GameConstStore";
import { unwrap } from "@/util/unwrap";
import { useLocalStorage } from "@vueuse/core";
const queryYearMonth = ref<number>();
const queryServerID = query.serverID;
const serverNick = staticValues.serverNick;
const lastYearMonth = ref(staticValues.lastYearMonth);
const firstYearMonth = ref(staticValues.firstYearMonth);
const currentYearMonth = ref(staticValues.currentYearMonth);
const asyncReady = ref<boolean>(false);
const gameConstStore = ref<GameConstStore>();
provide("gameConstStore", gameConstStore);
const storeP = getGameConstStore().then((store) => {
gameConstStore.value = store;
});
void Promise.all([storeP]).then(() => {
asyncReady.value = true;
});
const isNationRankingBottom = useLocalStorage(`${serverNick}_isNationRankingBottom`, false);
const mapName = staticValues.mapName;
const cityPosition = getCityPosition();
const formatCityInfoText = formatCityInfo;
const imagePath = window.pathConfig.gameImage;
const history = ref<HistoryObj>();
function generateYearMonthList(): { text: string; value: number }[] {
const result: { text: string; value: number }[] = [];
let yearMonth = firstYearMonth.value;
while (yearMonth <= lastYearMonth.value) {
const [year, month] = parseYearMonth(yearMonth);
const info: string[] = [];
if (queryYearMonth.value === yearMonth) {
info.push("선택");
}
result.push({ text: `${year}${month}${info.length > 0 ? `(${info.join(", ")})` : ""}`, value: yearMonth });
yearMonth += 1;
}
const [year, month] = parseYearMonth(yearMonth);
const info: string[] = [];
if (queryYearMonth.value === yearMonth) {
info.push("선택");
}
if (staticValues.serverID === query.serverID) {
info.push("현재");
result.push({ text: `${year}${month}${info.length > 0 ? `(${info.join(", ")})` : ""}`, value: yearMonth });
}
return result;
}
watch(queryYearMonth, async (yearMonth) => {
if (yearMonth === undefined) {
return;
}
if (yearMonth < firstYearMonth.value) {
queryYearMonth.value = firstYearMonth.value;
return;
}
if (staticValues.serverID === query.serverID) {
if (yearMonth > lastYearMonth.value + 1) {
queryYearMonth.value = lastYearMonth.value + 1;
return;
}
} else {
if (yearMonth > lastYearMonth.value) {
queryYearMonth.value = lastYearMonth.value;
return;
}
}
if (yearMonth > lastYearMonth.value && staticValues.serverID === query.serverID) {
try {
const result = await SammoAPI.Global.GetCurrentHistory();
history.value = result.data;
console.log(result);
const newLastYearMonth = joinYearMonth(result.data.year, result.data.month) - 1;
if (newLastYearMonth > lastYearMonth.value) {
lastYearMonth.value = newLastYearMonth;
currentYearMonth.value = newLastYearMonth + 1;
}
} catch (e) {
console.error(e);
return;
}
return;
}
const [year, month] = parseYearMonth(yearMonth);
try {
const result = await SammoAPI.Global.GetHistory[queryServerID][year][month]();
history.value = result.data;
console.log(result);
} catch (e) {
console.error(e);
return;
}
});
onMounted(() => {
queryYearMonth.value = (() => {
if (query.yearMonth === null) {
return staticValues.currentYearMonth;
}
if (query.yearMonth > staticValues.lastYearMonth + 1) {
return staticValues.currentYearMonth;
}
if (query.yearMonth < staticValues.firstYearMonth) {
return staticValues.currentYearMonth;
}
return query.yearMonth;
})();
});
</script>
<style lang="scss" scoped>
@import "@scss/common/break_500px.scss";
@include media-500px {
.optionMenu::v-deep .dropdown-toggle {
height: 32px;
}
.isNationRankingBottom {
.nation_position {
order: 4;
}
}
}
</style>
<style>
.hidden_but_copyable {
color: rgba(0, 0, 0, 0);
font-size: 0;
}
</style>
+590
View File
@@ -0,0 +1,590 @@
<template>
<TopBackBar :title="title" />
<div
id="container"
class="bg0 px-2"
style="max-width: 1000px; margin: auto; border: solid 1px #888888; overflow: hidden"
>
<div id="inheritance_list" class="row">
<template v-for="(text, key) in inheritanceViewText" :key="key">
<div :id="`inherit_${key}`" class="col col-sm-4 col-12 inherit_item inherit_template_item">
<div class="row">
<label :id="`inherit_${key}_head`" class="inherit_head col col-md-6 col-sm-7 col-6 col-form-label">{{
text.title
}}</label>
<div class="col col-md-6 col-sm-5 col-6">
<input
:id="`inherit_${key}_value`"
type="text"
class="form-control inherit_value f_tnum"
readonly
:value="Math.floor(items[key]).toLocaleString()"
/>
</div>
</div>
<div style="text-align: right">
<!-- eslint-disable-next-line vue/no-v-html -->
<small class="form-text text-muted" v-html="text.info" />
</div>
</div>
<div v-if="key == 'new'" style="width: 100%; padding: 0 10px">
<hr :style="{ opacity: 0.5 }" />
</div>
</template>
</div>
<div id="inheritance_store">
<div class="row">
<div class="col">
<div class="bg1 a-center">유산 포인트 상점</div>
</div>
</div>
<div class="row">
<div class="col offset-md-4 col-md-4 col-sm-6 col-12 py-2">
<div class="row px-4">
<div class="a-right col-6 align-self-center">다음 전투 특기 선택</div>
<div class="col-6">
<select v-model="nextSpecialWar" class="form-select col-6">
<option v-for="(info, key) in availableSpecialWar" :key="key" :value="key">
{{ info.title }}
</option>
</select>
</div>
</div>
<div class="a-right">
<small class="form-text text-muted"
><!-- eslint-disable-next-line vue/no-v-html -->
<span style="color: white" v-html="availableSpecialWar[nextSpecialWar].info" /><br />다음에 얻을 전투
특기를 정합니다.<br /><span style="color: white"
>필요 포인트: {{ inheritActionCost.nextSpecial }}</span
></small
>
</div>
<div class="row px-4">
<BButton class="col-6 offset-6" variant="primary" @click="setNextSpecialWar"> 구입 </BButton>
</div>
</div>
<div class="col col-md-4 col-sm-6 col-12 py-2">
<div class="row px-4">
<div class="a-right col-6 align-self-center">유니크 경매</div>
<div class="col-6">
<select v-model="specificUnique" class="form-select col-6">
<option disabled selected :value="null">유니크 선택</option>
<option v-for="(info, key) in availableUnique" :key="key" :value="key">
{{ info.title }}
</option>
</select>
</div>
</div>
<div class="row px-4">
<div class="col f_tnum">
<NumberInputWithInfo
v-model="specificUniqueAmount"
title="입찰 포인트"
:min="inheritActionCost.minSpecificUnique"
:max="items.previous"
/>
</div>
</div>
<div class="a-right">
<small class="form-text text-muted"
>얻고자 하는 유니크 아이템으로 경매를 시작합니다. 24 동안 진행됩니다.<br />
<!-- eslint-disable-next-line vue/no-v-html -->
<span style="color: white" v-html="specificUnique == null ? '' : availableUnique[specificUnique].info" />
</small>
</div>
<div class="row px-4">
<BButton class="col-6 offset-6" variant="primary" @click="openUniqueItemAuction"> 경매 시작 </BButton>
</div>
</div>
</div>
<div style="width: 100%; padding: 0 10px">
<hr :style="{ opacity: 0.5 }" />
</div>
<div class="row py-sm-2">
<div class="col col-md-4 col-sm-6 col-12 py-2">
<div class="row px-4">
<div class="a-right col-6 align-self-center">랜덤 초기화</div>
<BButton class="col-6" variant="primary" @click="buySimple('ResetTurnTime')"> 구입 </BButton>
</div>
<div class="a-right">
<small class="form-text text-muted"
>다음 턴이 랜덤으로 바뀝니다. (필요 포인트가 피보나치식으로 증가합니다)<br /><span style="color: white"
>필요 포인트: {{ inheritActionCost.resetTurnTime }}</span
></small
>
</div>
</div>
<div class="col col-md-4 col-sm-6 col-12 py-2">
<div class="row px-4">
<div class="a-right col-6 align-self-center">랜덤 유니크 획득</div>
<BButton class="col-6" variant="primary" @click="buySimple('BuyRandomUnique')"> 구입 </BButton>
</div>
<div class="a-right">
<small class="form-text text-muted"
>다음 턴에 랜덤 유니크를 얻습니다.<br /><span style="color: white"
>필요 포인트: {{ inheritActionCost.randomUnique }}</span
></small
>
</div>
</div>
<div class="col col-md-4 col-sm-6 col-12 py-2">
<div class="row px-4">
<div class="a-right col-6 align-self-center">즉시 전투 특기 초기화</div>
<BButton class="col-6" variant="primary" @click="buySimple('ResetSpecialWar')"> 구입 </BButton>
</div>
<div class="a-right">
<small class="form-text text-muted"
>즉시 전투 특기를 초기화합니다. (필요 포인트가 피보나치식으로 증가합니다)<br /><span style="color: white"
>필요 포인트: {{ inheritActionCost.resetSpecialWar }}</span
></small
>
</div>
</div>
</div>
</div>
<div style="width: 100%; padding: 0 10px">
<hr :style="{ opacity: 0.5 }" />
</div>
<div class="row">
<div v-for="(info, buffKey) in inheritBuffHelpText" :key="buffKey" class="col col-md-4 col-sm-6 col-12">
<div class="row">
<label class="col col-sm-6 col-form-label" :for="`buff-${buffKey}`">{{ info.title }}</label>
<div class="col col-sm-6 f_tnum">
<b-form-input
:id="`buff-${buffKey}`"
v-model.number="inheritBuff[buffKey]"
type="number"
:min="prevInheritBuff[buffKey] ?? 0"
:max="maxInheritBuff"
/>
</div>
</div>
<div style="text-align: right">
<small class="form-text text-muted f_tnum"
>{{ info.info }}<br /><span style="color: white"
>필요 포인트:
{{
inheritActionCost.buff[inheritBuff[buffKey]] - inheritActionCost.buff[prevInheritBuff[buffKey] ?? 0]
}}</span
></small
>
</div>
<div class="row px-4" style="margin-bottom: 1em">
<BButton
variant="secondary"
class="col col-md-6 col-4 offset-md-0 offset-4"
@click="inheritBuff[buffKey] = prevInheritBuff[buffKey] ?? 0"
>
리셋 </BButton
><BButton variant="primary" class="col col-md-6 col-4" @click="buyInheritBuff(buffKey)"> 구입 </BButton>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="bg1 a-center">유산 포인트 변경 내역</div>
</div>
</div>
<div v-for="[idx, log] of inheritPointLogs" :key="idx" class="row">
<div class="col a-right" style="max-width: 20ch">
<small class="text-muted tnum">[{{ log.date }}]</small>
</div>
<div class="col a-left">
{{ log.text }}
</div>
</div>
<div class="d-grid"><BButton @click="getMoreLog()"> 가져오기</BButton></div>
</div>
</template>
<script lang="ts">
type InheritanceType =
| "previous"
| "lived_month"
| "max_belong"
| "max_domestic_critical"
| "active_action"
// | "snipe_combat"
| "combat"
| "sabotage"
| "unifier"
| "dex"
| "tournament"
| "betting";
type InheritanceViewType = InheritanceType | "sum" | "new";
declare const staticValues: {
lastInheritPointLogs: InheritPointLogItem[];
items: Record<InheritanceType, number>;
currentInheritBuff: {
[v in inheritBuffType]: number | undefined;
};
maxInheritBuff: number;
inheritActionCost: {
buff: number[];
resetTurnTime: number;
resetSpecialWar: number;
randomUnique: number;
nextSpecial: number;
minSpecificUnique: number;
};
resetTurnTimeLevel: number;
resetSpecialWarLevel: number;
availableSpecialWar: Record<
string,
{
title: string;
info: string;
}
>;
availableUnique: Record<
string,
{
title: string;
rawName: string;
info: string;
}
>;
};
</script>
<script lang="ts" setup>
import { reactive, ref } from "vue";
import "@scss/common/bootstrap5.scss";
import "@scss/game_bg.scss";
import TopBackBar from "@/components/TopBackBar.vue";
import _ from "lodash";
import NumberInputWithInfo from "@/components/NumberInputWithInfo.vue";
import { SammoAPI } from "./SammoAPI";
import type { inheritBuffType, InheritPointLogItem } from "./defs/API/InheritAction";
import * as JosaUtil from "@/util/JosaUtil";
import { BButton } from "bootstrap-vue-3";
import { unwrap } from "./util/unwrap";
const inheritanceViewText: Record<InheritanceViewType, { title: string; info: string }> = {
sum: {
title: "총 포인트",
info: "다음 플레이에서 사용할 수 있는 총 포인트입니다.",
},
previous: {
title: "기존 포인트",
info: "이전에 물려받은 포인트입니다.",
},
new: {
title: "신규 포인트",
info: "이번 플레이에서 얻은 총 포인트입니다.",
},
lived_month: {
title: "생존",
info: "살아남은 기간입니다. (1개월 단위)",
},
max_belong: {
title: "최대 임관년 수",
info: "가장 오래 임관했던 국가의 연도입니다.",
},
max_domestic_critical: {
title: "최대 연속 내정 성공",
info: "성공한 내정 중 최대 연속값입니다.",
},
active_action: {
title: "능동 행동 수",
info: "장수 동향에 본인의 이름이 직접 나타난 수입니다.<br>일부 사령턴은 제외됩니다.",
},
/* snipe_combat: {
title: "병종 상성 우위 횟수",
info: "유리한 상성을 가지고 전투했습니다.",
},*/
combat: {
title: "전투 횟수",
info: "전투 횟수입니다.",
},
sabotage: {
title: "계략 성공 횟수",
info: "계략 성공 횟수입니다.",
},
unifier: {
title: "천통 기여",
info: "천통에 기여한 포인트입니다. <br>각 국의 군주, 천통 수뇌, 천통 군주가 받습니다.",
},
dex: {
title: "숙련도",
info: "총 숙련도합입니다. <br>최대 숙련 이후에는 상승량이 1/3로 감소합니다.",
},
tournament: {
title: "토너먼트",
info: "토너먼트 입상 포인트입니다.",
},
betting: {
title: "베팅 당첨",
info: "성공적인 베팅을 했습니다. <br>수익율과 베팅 성공 횟수를 따릅니다.",
},
};
const inheritBuffHelpText: Record<
inheritBuffType,
{
title: string;
info: string;
}
> = {
warAvoidRatio: {
title: "회피 확률 증가",
info: "전투 시 회피 확률이 1%p ~ 5%p 증가합니다.",
},
warCriticalRatio: {
title: "필살 확률 증가",
info: "전투 시 필살 확률이 1%p ~ 5%p 증가합니다.",
},
warMagicTrialProb: {
title: "계략 시도 확률 증가",
info: "전투 시 계략을 시도할 확률이 1%p ~ 5%p 증가합니다. 무장도 계략을 시도합니다.",
},
warAvoidRatioOppose: {
title: "상대 회피 확률 감소",
info: "전투 시 상대의 회피 확률이 1%p ~ 5%p 감소합니다.",
},
warCriticalRatioOppose: {
title: "상대 필살 확률 감소",
info: "전투 시 상대의 필살 확률이 1%p ~ 5%p 감소합니다.",
},
warMagicTrialProbOppose: {
title: "상대 계략 시도 확률 감소",
info: "전투 시 상대의 계략 시도 확률이 1%p ~ 5%p 감소합니다.",
},
domesticSuccessProb: {
title: "내정 성공 확률 증가",
info: "민심, 인구, 농업, 상업, 치안, 수비, 성벽, 기술 내정의 성공 확률이 1%p ~ 5%p 증가합니다.",
},
domesticFailProb: {
title: "내정 실패 확률 감소",
info: "민심, 인구, 농업, 상업, 치안, 수비, 성벽, 기술 내정의 실패 확률이 1%p ~ 5%p 감소합니다.",
},
};
const inheritBuff = reactive({} as Record<inheritBuffType, number>);
for (const buffKey of Object.keys(inheritBuffHelpText) as inheritBuffType[]) {
inheritBuff[buffKey] = staticValues.currentInheritBuff[buffKey] ?? 0;
}
const title = "유산 관리";
const items = ref(
(() => {
const totalPoint = Math.floor(_.sum(Object.values(staticValues.items)));
const previousPoint = Math.floor(staticValues.items["previous"]);
const newPoint = Math.floor(totalPoint - previousPoint);
const result: Record<InheritanceViewType, number> = {
...staticValues.items,
sum: totalPoint,
new: newPoint,
};
return result;
})()
);
const {
maxInheritBuff,
inheritActionCost,
currentInheritBuff: prevInheritBuff,
availableSpecialWar,
availableUnique,
} = staticValues;
const nextSpecialWar = ref(Object.keys(availableSpecialWar)[0]);
const specificUnique = ref<string | null>(null);
const specificUniqueAmount = ref(inheritActionCost.minSpecificUnique);
const lastLogID = ref(Math.min(...staticValues.lastInheritPointLogs.map((v) => v.id)));
const inheritPointLogs = ref(
(() => {
const logs = new Map<number, InheritPointLogItem>();
for (const log of staticValues.lastInheritPointLogs) {
logs.set(log.id, log);
}
return logs;
})()
);
staticValues.lastInheritPointLogs;
async function buyInheritBuff(buffKey: inheritBuffType) {
const level = Math.floor(unwrap(inheritBuff[buffKey]));
const prevLevel = prevInheritBuff[buffKey] ?? 0;
if (level == prevLevel) {
return;
}
if (level < prevLevel) {
alert("낮출 수 없습니다.");
return;
}
const cost = inheritActionCost.buff[level] - inheritActionCost.buff[prevLevel];
if (items.value.previous < cost) {
alert("유산 포인트가 부족합니다.");
return;
}
const name = inheritBuffHelpText[buffKey].title;
const josaUl = JosaUtil.pick(name, "을");
if (!confirm(`${name}${josaUl} ${level}등급으로 올릴까요? ${cost} 포인트가 소모됩니다.`)) {
return;
}
try {
await SammoAPI.InheritAction.BuyHiddenBuff({
type: buffKey,
level,
});
} catch (e) {
console.error(e);
alert(`실패했습니다: ${e}`);
return;
}
alert("성공했습니다.");
//TODO: 페이지 새로고침 필요없이 하도록
location.reload();
}
async function buySimple(type: "ResetTurnTime" | "BuyRandomUnique" | "ResetSpecialWar") {
const costMap: Record<typeof type, number> = {
ResetTurnTime: inheritActionCost.resetTurnTime,
ResetSpecialWar: inheritActionCost.resetSpecialWar,
BuyRandomUnique: inheritActionCost.randomUnique,
};
const cost = costMap[type];
if (cost === undefined) {
alert(`올바르지 않은 타입:${type}`);
return;
}
const messageMap: Record<typeof type, string> = {
ResetTurnTime: `${cost} 포인트로 턴을 초기화 하시겠습니까?`,
ResetSpecialWar: `${cost} 포인트로 전투 특기를 초기화 하시겠습니까?`,
BuyRandomUnique: `${cost} 포인트로 랜덤 유니크를 구입하시겠습니까?`,
};
if (items.value.previous < cost) {
alert("유산 포인트가 부족합니다.");
return;
}
if (!confirm(messageMap[type])) {
return;
}
try {
await SammoAPI.InheritAction[type]();
} catch (e) {
console.error(e);
alert(`실패했습니다: ${e}`);
return;
}
alert("성공했습니다.");
//TODO: 페이지 새로고침 필요없이 하도록
location.reload();
}
async function setNextSpecialWar() {
const specialWarName = availableSpecialWar[nextSpecialWar.value].title ?? undefined;
if (specialWarName === undefined) {
alert(`잘못된 타입: ${nextSpecialWar.value}`);
return;
}
const cost = inheritActionCost.nextSpecial;
if (items.value.previous < cost) {
alert("유산 포인트가 부족합니다.");
return;
}
const josaRo = JosaUtil.pick(specialWarName, "로");
if (!confirm(`${cost} 포인트로 다음 전특을 ${specialWarName}${josaRo} 고정하겠습니까?`)) {
return;
}
try {
await SammoAPI.InheritAction.SetNextSpecialWar({
type: nextSpecialWar.value,
});
} catch (e) {
console.error(e);
alert(`실패했습니다: ${e}`);
return;
}
alert("성공했습니다.");
//TODO: 페이지 새로고침 필요없이 하도록
location.reload();
}
async function openUniqueItemAuction() {
if (specificUnique.value === null) {
alert("유니크를 선택해주세요.");
return;
}
const uniqueName = availableUnique[specificUnique.value].title ?? undefined;
if (uniqueName === undefined) {
alert(`잘못된 타입: ${specificUnique.value}`);
return;
}
const uniqueRawName = availableUnique[specificUnique.value].rawName ?? undefined;
const amount = specificUniqueAmount.value;
if (items.value.previous < amount) {
alert("유산 포인트가 부족합니다.");
return;
}
const josaUl = JosaUtil.pick(uniqueRawName, "을");
if (!confirm(`${amount} 포인트로 ${uniqueName}${josaUl} 입찰하겠습니까?`)) {
return;
}
try {
await SammoAPI.Auction.OpenUniqueAuction({
itemID: specificUnique.value,
amount,
});
} catch (e) {
console.error(e);
alert(`실패했습니다: ${e}`);
return;
}
alert("성공했습니다. 경매장을 확인해주세요.");
//TODO: 페이지 새로고침 필요없이 하도록
location.reload();
}
async function getMoreLog(): Promise<void>{
try{
const result = await SammoAPI.InheritAction.GetMoreLog({
lastID: lastLogID.value
});
for(const log of result.log){
inheritPointLogs.value.set(log.id, log);
lastLogID.value = Math.min(lastLogID.value, log.id);
}
}catch(e){
console.error(e);
alert(`실패했습니다: ${e}`);
return;
}
}
</script>
<style>
.col-form-label {
text-align: right;
padding-right: 2ch;
}
.inherit_value {
text-align: right;
}
.tnum {
font-feature-settings: "tnum";
}
</style>
+636
View File
@@ -0,0 +1,636 @@
<template>
<TopBackBar title="장수 생성" />
<div v-if="gameConstStore && args" id="container" class="bg0">
<div class="nation-list">
<div class="nation-header nation-row bg1 center">
<div>국가명</div>
<div>임관권유문</div>
<div class="display-toggle d-grid">
<b-button
v-model="displayTable"
:pressed="displayTable"
:variant="displayTable ? 'info' : 'secondary'"
@click="displayTable = !displayTable"
>
{{ displayTable ? "숨기기" : "보이기" }}
</b-button>
</div>
<div class="zoom-toggle d-grid">
<b-button
v-model="toggleZoom"
:pressed="toggleZoom"
:variant="toggleZoom ? 'info' : 'secondary'"
:disabled="!displayTable"
@click="toggleZoom = !toggleZoom"
>
{{ toggleZoom ? "작게 보기" : "크게 보기" }}
</b-button>
</div>
</div>
<template v-if="displayTable">
<div
v-for="nation in nationList"
:key="nation.nation"
:class="['nation-row', 's-border-b', toggleZoom ? 'on-zoom' : 'on-fit']"
>
<div
:style="{
backgroundColor: nation.color,
color: isBrightColor(nation.color) ? 'black' : 'white',
fontSize: '1.3em',
}"
class="d-grid"
>
<div class="align-self-center center">
{{ nation.name }}
</div>
</div>
<div class="nation-scout-plate align-self-center">
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="nation-scout-msg" v-html="nation.scoutmsg ?? '-'" />
</div>
</div>
</template>
</div>
<!-- 국가 설명 -->
<div class="row bg1">
<div class="col center">장수 생성</div>
</div>
<div class="forms">
<div class="row">
<div class="col col-md-4 col-3 a-right align-self-center">장수명</div>
<div class="col col-md-3 col-9 align-self-center">
<input v-model="args.name" class="form-control" />
</div>
<div class="col col-md-1 col-3 a-right align-self-center">전콘 사용</div>
<div class="col col-md-4 col-9 align-self-center">
<img style="height: 64px; width: 64px" :src="iconPath" />
<label> <input v-model="args.pic" type="checkbox" /> 사용 </label>
</div>
<div class="col col-md-4 col-3 align-self-center a-right">성격</div>
<div class="col col-md-8 col-9 align-self-center">
<div class="row">
<div class="col col-md-3 col-4 align-self-center">
<select v-model="args.character" class="form-select form-inline" style="max-width: 20ch">
<option v-for="(personalityObj, key) in availablePersonality" :key="key" :value="key">
{{ personalityObj.name }}
</option>
</select>
</div>
<div class="col col-md-9 col-8 align-self-center">
<small class="text-muted">
{{ availablePersonality[args.character].info }}
</small>
</div>
</div>
</div>
</div>
<!--<div class="row">
<div class="col">
계정관리에서 자신만을 표현할 있는 아이콘을 업로드 해보세요!
</div>
</div>-->
<div class="row" style="margin-top: 1em">
<div class="col col-md-4 col-3 a-right align-self-center">
능력치
<br />
<small class="text-muted">//</small>
</div>
<div class="col col-md-2 col-3 align-self-center">
<input v-model.number="args.leadership" type="number" class="form-control" />
</div>
<div class="col col-md-2 col-3 align-self-center">
<input v-model.number="args.strength" type="number" class="form-control" />
</div>
<div class="col col-md-2 col-3 align-self-center">
<input v-model.number="args.intel" type="number" class="form-control" />
</div>
</div>
<div class="row" style="margin-top: 1em">
<div class="col col-md-4 col-3 a-right align-self-center">능력치 조절</div>
<div class="col col-md-8 col-9">
<b-button variant="secondary" class="stat-btn" @click="randStatRandom"> 랜덤형 </b-button>
<b-button variant="secondary" class="stat-btn" @click="randStatLeadPow"> 통솔무력형 </b-button>
<b-button variant="secondary" class="stat-btn" @click="randStatLeadInt"> 통솔지력형 </b-button>
<b-button variant="secondary" class="stat-btn" @click="randStatPowInt"> 무력지력형 </b-button>
</div>
</div>
</div>
<div class="row" style="border-top: solid 1px #aaa; margin-top: 0.5em">
<div class="col a-center" style="color: orange">
모든 능력치는 ( {{ stats.min }} &lt;= 능력치 &lt;= {{ stats.max }} ) 사이로 잡으셔야 합니다. <br /> 외의
능력치는 가입되지 않습니다.
</div>
</div>
<div class="row">
<div class="col a-center">
능력치의 총합은 {{ stats.total }} 입니다. 가입후 {{ stats.bonusMin }} ~ {{ stats.bonusMax }} 능력치 보너스를
받게 됩니다. <br />임의의 도시에서 재야로 시작하며 건국과 임관은 게임 내에서 실행합니다.
</div>
</div>
<div class="row bg1">
<div class="col col-md-11 col-9 center align-self-center">유산 포인트 사용</div>
<div class="col col-md-1 col-3">
<label>
<input v-model="displayInherit" type="checkbox" />
{{ displayInherit ? "숨기기" : "보이기" }}
</label>
</div>
</div>
<div v-if="displayInherit" class="inherit-block">
<div class="row">
<div class="col">
<NumberInputWithInfo v-model.number="inheritTotalPoint" title="보유한 유산 포인트" :readonly="true" />
</div>
<div class="col">
<NumberInputWithInfo v-model.number="inheritRequiredPoint" title="필요 유산 포인트" :readonly="true" />
</div>
</div>
<hr />
<div class="row">
<div class="col col-md-6 col-sm-6 col-12 p-2 align-self-center">
<div class="row">
<div class="col col-6 a-right align-self-center">천재로 생성</div>
<div class="col col-6 align-self-center">
<select v-model="args.inheritSpecial" class="form-select form-inline" style="max-width: 20ch">
<option :value="undefined">사용안함</option>
<option v-for="(inheritSpecial, key) in availableInheritSpecial" :key="key" :value="key">
{{ inheritSpecial.name }}
</option>
</select>
</div>
</div>
<div class="col align-self-center">
<!-- eslint-disable-next-line vue/no-v-html -->
<small class="text-muted" v-html="availableInheritSpecial[args.inheritSpecial ?? '']?.info" />
</div>
</div>
<div class="col col-md-6 col-sm-6 col-12 p-2 align-self-center">
<div class="row">
<div class="col col-6 a-right align-self-center">도시</div>
<div class="col col-6 align-self-center">
<select v-model.number="inheritCity" class="form-select form-inline" style="max-width: 20ch">
<option :value="undefined">사용안함</option>
<option v-for="city in availableInheritCity" :key="city.id" :value="city.id">
{{ `[${city.region}] ${city.name}` }}
</option>
</select>
</div>
</div>
</div>
<div class="col col-md-6 col-sm-6 col-12 p-2 align-self-center">
<div class="a-center">
<label> <input v-model="inheritTurnTimeSet" type="checkbox" /> 시간 고정 </label>
</div>
<div class="row turn_time_pad">
<div class="col col-md-4 offset-md-3 col-4 offset-3">
<NumberInputWithInfo
v-model="inheritTurnTimeMinute"
:readonly="!inheritTurnTimeSet"
:min="0"
:max="1 - turnterm"
:right="true"
title="분"
/>
</div>
<div class="col col-md-4 col-4">
<NumberInputWithInfo
v-model="inheritTurnTimeSecond"
:readonly="!inheritTurnTimeSet"
:min="0"
:max="60"
:right="true"
title="초"
/>
</div>
</div>
</div>
<div class="col col-md-6 col-12 p-2">
<div class="a-center">추가 능력치 고정</div>
<div class="row">
<div class="col">
<NumberInputWithInfo
v-model="(args.inheritBonusStat ?? [0, 0, 0])[0]"
:max="stats.bonusMax"
title="통솔"
/>
</div>
<div class="col">
<NumberInputWithInfo
v-model="(args.inheritBonusStat ?? [0, 0, 0])[1]"
:max="stats.bonusMax"
title="무력"
/>
</div>
<div class="col">
<NumberInputWithInfo
v-model="(args.inheritBonusStat ?? [0, 0, 0])[2]"
:max="stats.bonusMax"
title="지력"
/>
</div>
</div>
</div>
</div>
</div>
<div class="row" style="border-top: solid 1px #aaa">
<div class="col a-center" style="margin: 0.5em">
<b-button color="primary" @click="submitForm"> 장수 생성 </b-button>&nbsp;
<b-button color="secondary" @click="resetArgs"> 다시 입력 </b-button>
</div>
</div>
</div>
</template>
<script lang="ts">
declare const staticValues: {
nationList: {
nation: number;
name: string;
color: string;
scout: number;
scoutmsg?: string;
}[];
turnterm: number;
member: {
name: string;
grade: number;
picture: string;
imgsvr: 0 | 1;
};
serverID: string;
inheritTotalPoint: number;
};
</script>
<script lang="ts" setup>
import "@scss/common/bootstrap5.scss";
import "@scss/game_bg.scss";
import TopBackBar from "@/components/TopBackBar.vue";
import { getIconPath } from "@util/getIconPath";
import { isBrightColor } from "@util/isBrightColor";
import { abilityLeadint, abilityLeadpow, abilityPowint, abilityRand } from "@util/generalStats";
import { shuffle, sum } from "lodash";
import NumberInputWithInfo from "@/components/NumberInputWithInfo.vue";
import { SammoAPI } from "./SammoAPI";
import type { JoinArgs } from "./defs/API/General";
import { GameConstStore, getGameConstStore } from "@/GameConstStore";
import { computed, onMounted, ref, watch } from "vue";
import type { CityID, GameIActionInfo } from "./defs/GameObj";
import { unwrap } from "@/util/unwrap";
const gameConstStore = ref<GameConstStore>();
const { serverID, member, turnterm } = staticValues;
const nationList = ref(shuffle(staticValues.nationList));
const args = ref<JoinArgs>();
const stats = ref({
min: 50,
max: 50,
total: 50,
bonusMin: 1,
bonusMax: 5,
});
const inheritTotalPoint = ref(staticValues.inheritTotalPoint);
const availableInheritCity = ref<{ id: CityID; name: string; region: string }[]>([]);
const availableInheritSpecial = ref<Record<string, GameIActionInfo>>({});
const availablePersonality = ref<Record<string, GameIActionInfo>>({});
watch(gameConstStore, (gameConst) => {
if (gameConst === undefined) {
console.error();
return;
}
availableInheritCity.value = (() => {
const cityList: { id: CityID; name: string; region: string }[] = [];
for (const city of Object.values(gameConst.cityConst)) {
cityList.push({
id: city.id,
name: city.name,
region: gameConst.cityConstMap.region[city.region] as string,
});
}
cityList.sort((lhs, rhs) => {
const rC = lhs.region.localeCompare(rhs.region);
if (rC != 0) return rC;
return lhs.name.localeCompare(rhs.name);
});
return cityList;
})();
availableInheritSpecial.value = (() => {
const specialList: typeof availableInheritSpecial.value = {};
for (const specialWarID of Object.values(gameConst.gameConst.availableSpecialWar)) {
specialList[specialWarID] = unwrap(gameConst.iActionInfo.specialWar[specialWarID]);
}
return specialList;
})();
availablePersonality.value = (() => {
const personalityList: typeof availablePersonality.value = {};
for (const personalityID of Object.values(gameConst.gameConst.availablePersonality)) {
personalityList[personalityID] = unwrap(gameConst.iActionInfo.personality[personalityID]);
}
personalityList["Random"] = {
value: "Random",
name: "???",
info: "무작위 성격을 선택합니다.",
};
return personalityList;
})();
stats.value = {
min: gameConst.gameConst.defaultStatMin,
max: gameConst.gameConst.defaultStatMax,
total: gameConst.gameConst.defaultStatTotal,
bonusMin: gameConst.gameConst.defaultStatMin,
bonusMax: gameConst.gameConst.defaultStatMax,
};
args.value = {
name: member.name,
leadership: stats.value.total - 2 * Math.floor(stats.value.total / 3),
strength: Math.floor(stats.value.total / 3),
intel: Math.floor(stats.value.total / 3),
pic: true,
character: "Random",
inheritCity: undefined,
inheritBonusStat: [0, 0, 0],
inheritSpecial: undefined,
inheritTurntime: undefined,
};
});
onMounted(async () => {
gameConstStore.value = await getGameConstStore();
});
function randStatRandom() {
if (args.value === undefined) throw "nyc";
[args.value.leadership, args.value.strength, args.value.intel] = abilityRand(stats.value);
}
function randStatLeadPow() {
if (args.value === undefined) throw "nyc";
[args.value.leadership, args.value.strength, args.value.intel] = abilityLeadpow(stats.value);
}
function randStatLeadInt() {
if (args.value === undefined) throw "nyc";
[args.value.leadership, args.value.strength, args.value.intel] = abilityLeadint(stats.value);
}
function randStatPowInt() {
if (args.value === undefined) throw "nyc";
[args.value.leadership, args.value.strength, args.value.intel] = abilityPowint(stats.value);
}
function resetArgs() {
if (!args.value) throw "nyc";
const defaultStatTotal = stats.value.total;
args.value.name = member.name;
args.value.pic = true;
args.value.character = "Random";
args.value.leadership = defaultStatTotal - 2 * Math.floor(defaultStatTotal / 3);
args.value.strength = Math.floor(defaultStatTotal / 3);
args.value.intel = Math.floor(defaultStatTotal / 3);
}
async function submitForm() {
if (!args.value) throw "nyc";
//검증은 언제 되어야 하는가?
const subbmitArgs = args.value;
const totalStat = subbmitArgs.leadership + subbmitArgs.strength + subbmitArgs.intel;
const defaultStatTotal = stats.value.total;
if (totalStat < defaultStatTotal) {
if (
!confirm(
`설정한 능력치가 ${totalStat}으로, 실제 최대치인 ${defaultStatTotal}보다 적습니다.\r\n그래도 진행할까요?`
)
) {
return false;
}
}
try {
await SammoAPI.General.Join(subbmitArgs);
} catch (e) {
console.error(e);
alert(`실패했습니다: ${e}`);
return;
}
alert("정상적으로 생성되었습니다. \n위키와 팁/강좌 게시판을 꼭 읽어보세요!");
location.href = "./";
}
const iconPath = computed(() => {
if (!args.value) throw "nyc";
if (args.value.pic) {
return getIconPath(member.imgsvr, member.picture);
}
return getIconPath(0, "default.jpg");
});
const inheritRequiredPoint = computed(() => {
if (!args.value || !gameConstStore.value) throw "nyc";
let inheritRequiredPoint = 0;
const gameConst = gameConstStore.value.gameConst;
if (args.value.inheritCity !== undefined) {
inheritRequiredPoint += gameConst.inheritBornCityPoint;
}
if (args.value.inheritSpecial !== undefined) {
inheritRequiredPoint += gameConst.inheritBornSpecialPoint;
}
if (args.value.inheritTurntime !== undefined) {
inheritRequiredPoint += gameConst.inheritBornTurntimePoint;
}
if (args.value.inheritBonusStat !== undefined && sum(args.value.inheritBonusStat) != 0) {
inheritRequiredPoint += gameConst.inheritBornStatPoint;
}
return inheritRequiredPoint;
});
const toggleZoom = ref(true);
const displayTable = ref<boolean>(JSON.parse(localStorage.getItem(`conf.${serverID}.join.displayTable`) ?? "true"));
watch(displayTable, (newValue: boolean) => {
localStorage.setItem(`conf.${serverID}.join.displayTable`, JSON.stringify(newValue));
});
const displayInherit = ref<boolean>(JSON.parse(localStorage.getItem(`conf.${serverID}.join.displayInherit`) ?? "true"));
watch(displayInherit, (newValue: boolean) => {
localStorage.setItem(`conf.${serverID}.join.displayInherit`, JSON.stringify(newValue));
});
const inheritCity = ref<number>();
watch(inheritCity, (newValue: undefined | number) => {
if (!args.value) throw "nyc";
if (!newValue) {
args.value.inheritCity = undefined;
return;
}
args.value.inheritCity = inheritCity.value;
});
const inheritTurnTimeSet = ref(false);
watch(inheritTurnTimeSet, (newValue: boolean) => {
if (!args.value) throw "nyc";
if (!newValue) {
args.value.inheritTurntime = undefined;
return;
}
args.value.inheritTurntime = inheritTurnTimeMinute.value * 60 + inheritTurnTimeSecond.value;
});
watch(
[inheritCity, inheritTurnTimeSet],
([newInheritCity, newInheritTurnTimeSet], [oldInheritCity, oldInheritTurnTimeSet]) => {
if (newInheritCity === undefined || newInheritTurnTimeSet === false) {
return;
}
alert("도시와 턴 시간을 동시에 설정할 수 없습니다.");
if (newInheritCity !== oldInheritCity) {
inheritCity.value = undefined;
}
if (newInheritTurnTimeSet !== oldInheritTurnTimeSet) {
inheritTurnTimeSet.value = false;
}
},
{ immediate: true }
);
const inheritTurnTimeMinute = ref(0);
watch(inheritTurnTimeMinute, (newValue: number) => {
if (!args.value) throw "nyc";
if (!inheritTurnTimeSet.value) {
args.value.inheritTurntime = undefined;
return;
}
args.value.inheritTurntime = newValue * 60 + inheritTurnTimeSecond.value;
});
const inheritTurnTimeSecond = ref(0);
watch(inheritTurnTimeSecond, (newValue: number) => {
if (!args.value) throw "nyc";
if (!inheritTurnTimeSet.value) {
args.value.inheritTurntime = undefined;
return;
}
args.value.inheritTurntime = inheritTurnTimeMinute.value * 60 + newValue;
});
</script>
<style lang="scss">
@import "@scss/common/bootstrap5.scss";
@import "@scss/editor_component.scss";
#container {
width: 100%;
max-width: 1000px;
margin: auto;
border: solid 1px #888888;
overflow: hidden;
}
.forms {
padding: 0 6px;
}
.stat-btn {
width: 8em;
margin-right: 1px;
margin-bottom: 1px;
}
.col-form-label {
text-align: right;
}
.turn_time_pad .col-form-label {
text-align: left;
}
</style>
<style lang="scss" scoped>
@import "@scss/common/break_500px.scss";
@include media-1000px {
.nation-list .nation-row {
display: grid;
grid-template-columns: 130px 870px;
}
.zoom-toggle {
display: none;
}
.zoom-toggle > * {
display: none;
}
}
@include media-500px {
.nation-list .nation-row.nation-header {
display: grid;
grid-template-columns: 3fr 1fr 1fr;
grid-template-rows: 1fr 1fr;
.display-toggle {
grid-column: 2/3;
grid-row: 1/3;
}
.zoom-toggle {
grid-column: 3/4;
grid-row: 1/3;
}
}
.nation-list .nation-row {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr minmax(1fr, calc(200px * 500 / 870));
}
.on-fit {
.nation-scout-plate {
max-height: calc(200px * 500 / 870);
overflow: hidden;
}
.nation-scout-msg {
width: 870px;
transform-origin: 0px 0px;
transform: scale(calc(500 / 870));
}
}
.on-zoom {
.nation-scout-plate {
max-height: 200px;
overflow-y: hidden;
overflow-x: auto;
}
.nation-scout-msg {
max-width: 870px;
}
}
}
</style>
+719
View File
@@ -0,0 +1,719 @@
<template>
<top-back-bar :title="title" />
<BContainer
id="container"
class="tb_layout bg0"
:toast="{ root: true }"
:style="{ maxWidth: '1000px', margin: 'auto', border: 'solid 1px #888888', padding: '0' }"
>
<div class="bg1 section_bar">국가 정책</div>
<div class="px-3" style="text-align: right">
<small class="form-text text-muted">
최근 설정:
{{ lastSetters.policy.setter ?? "-없음-" }}
({{ lastSetters.policy.date ?? "설정 기록 없음" }})
</small>
</div>
<div class="form_list row row-cols-md-2 row-cols-1">
<div class="col">
<NumberInputWithInfo v-model="nationPolicy.reqNationGold" :step="100" title="국가 권장 금">
이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)
</NumberInputWithInfo>
</div>
<div class="col">
<NumberInputWithInfo v-model="nationPolicy.reqNationRice" :step="100" title="국가 권장 쌀">
이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)
</NumberInputWithInfo>
</div>
<div class="col">
<NumberInputWithInfo v-model="nationPolicy.reqHumanWarUrgentGold" :step="100" title="유저전투장 긴급포상 금">
유저장긴급포상시 이보다 금이 적은 장수에게 포상합니다.
<br />
0이면 보병 6 징병({{ (defaultStatMax * 100).toLocaleString() }} * 6) 가능한 금을 기준으로 하며, 수치는
현재 {{ zeroPolicy.reqHumanWarUrgentGold.toLocaleString() }}입니다.
</NumberInputWithInfo>
</div>
<div class="col">
<NumberInputWithInfo v-model="nationPolicy.reqHumanWarUrgentRice" :step="100" title="유저전투장 긴급포상 쌀">
유저장긴급포상시 이보다 쌀이 적은 장수에게 포상합니다.
<br />
0이면 기본 병종으로 {{ (defaultStatMax * 100).toLocaleString() }} * 6 사살 가능한 쌀을 기준으로 하며,
수치는 현재 {{ zeroPolicy.reqHumanWarUrgentRice.toLocaleString() }}입니다.
</NumberInputWithInfo>
</div>
<div class="col">
<NumberInputWithInfo v-model="nationPolicy.reqHumanWarRecommandGold" :step="100" title="유저전투장 권장 금">
유저전투장에게 주는 금입니다. 이보다 적으면 포상합니다.
<br />
0이면 유저전투장 긴급포상 금의 2배를 기준으로 하며, 수치는 현재
{{ (calcPolicyValue("reqHumanWarUrgentGold") * 2).toLocaleString() }}입니다.
</NumberInputWithInfo>
</div>
<div class="col">
<NumberInputWithInfo v-model="nationPolicy.reqHumanWarRecommandRice" :step="100" title="유저전투장 권장 쌀">
유저전투장에게 주는 쌀입니다. 이보다 적으면 포상합니다.
<br />
0이면 유저전투장 긴급포상 쌀의 2배를 기준으로 하며, 수치는 현재
{{ (calcPolicyValue("reqHumanWarUrgentRice") * 2).toLocaleString() }}입니다.
</NumberInputWithInfo>
</div>
<div class="col">
<NumberInputWithInfo v-model="nationPolicy.reqHumanDevelGold" :step="100" title="유저내정장 권장 금">
유저내정장에게 주는 금입니다. 이보다 적으면 포상합니다.
</NumberInputWithInfo>
</div>
<div class="col">
<NumberInputWithInfo v-model="nationPolicy.reqHumanDevelRice" :step="100" title="유저내정장 권장 쌀">
유저내정장에게 주는 쌀입니다. 이보다 적으면 포상합니다.
</NumberInputWithInfo>
</div>
<div class="col">
<NumberInputWithInfo v-model="nationPolicy.reqNPCWarGold" :step="100" title="NPC전투장 권장 금">
NPC전투장에게 주는 금입니다. 이보다 적으면 포상합니다.
<br />
0이면 기본 병종 4({{ (defaultStatNPCMax * 100).toLocaleString() }}
* 4) 징병비를 기준으로 하며, 수치는 현재
{{ zeroPolicy.reqNPCWarGold.toLocaleString() }}입니다.
</NumberInputWithInfo>
</div>
<div class="col">
<NumberInputWithInfo v-model="nationPolicy.reqNPCWarRice" :step="100" title="NPC전투장 권장 쌀">
NPC전투장에게 주는 쌀입니다. 이보다 적으면 포상합니다.
<br />
0이면 기본 병종으로
{{ (defaultStatNPCMax * 100).toLocaleString() }} * 4 사살 가능한 쌀을 기준으로 하며, 수치는 현재
{{ zeroPolicy.reqNPCWarRice.toLocaleString() }}입니다.
</NumberInputWithInfo>
</div>
<div class="col">
<NumberInputWithInfo v-model="nationPolicy.reqNPCDevelGold" :step="100" title="NPC내정장 권장 금">
NPC내정장에게 주는 금입니다. 이보다 5배 많다면 헌납합니다.
<br />
0이면 30 내정 가능한 금을 기준으로 하며, 수치는 현재
{{ zeroPolicy.reqNPCDevelGold }}입니다.
</NumberInputWithInfo>
</div>
<div class="col">
<NumberInputWithInfo v-model="nationPolicy.reqNPCDevelRice" :step="100" title="NPC내정장 권장 쌀">
NPC내정장에게 주는 쌀입니다. 이보다 5배 많다면 헌납합니다.
</NumberInputWithInfo>
</div>
<div class="col">
<NumberInputWithInfo
v-model="nationPolicy.minimumResourceActionAmount"
:step="100"
:min="100"
title="포상/몰수/헌납/삼/팜 최소 단위"
>
연산결과가 단위보다 적다면 수행하지 않습니다.
</NumberInputWithInfo>
</div>
<div class="col">
<NumberInputWithInfo
v-model="nationPolicy.maximumResourceActionAmount"
:step="100"
:min="100"
title="포상/몰수/헌납/삼/팜 최대 단위"
>
연산결과가 단위보다 크다면, 값에 맞춥니다.
</NumberInputWithInfo>
</div>
<div class="col">
<NumberInputWithInfo v-model="nationPolicy.minWarCrew" :step="50" title="최소 전투 가능 병력 수">
이보다 적을 때에는 징병을 시도합니다.
</NumberInputWithInfo>
</div>
<div class="col">
<NumberInputWithInfo
v-model="nationPolicy.minNPCRecruitCityPopulation"
:step="100"
title="NPC 최소 징병 가능 인구 수"
>
도시의 인구가 이보다 낮으면 NPC는 도시에서 징병하지 않고 후방 워프합니다.
<br />NPC의 최대 병력수보다 낮게 설정하면 제자리에서 정착장려를 합니다.
</NumberInputWithInfo>
</div>
<div class="col">
<NumberInputWithInfo
:modelValue="nationPolicy.safeRecruitCityPopulationRatio * 100"
:step="0.5"
:int="false"
:min="0"
:max="100"
title="제자리 징병 허용 인구율(%)"
@update:modelValue="nationPolicy.safeRecruitCityPopulationRatio = $event / 100"
>
전쟁 후방 발령, 후방 워프의 기준 인구입니다. 이보다 많다면 '충분하다' 판단합니다.
<br />NPC의 최대 병력수보다 낮게 설정하면 제자리에서 정착장려를 합니다.
</NumberInputWithInfo>
</div>
<div class="col">
<NumberInputWithInfo v-model="nationPolicy.minNPCWarLeadership" :step="5" title="NPC 전투 참여 통솔 기준">
수치보다 같거나 높으면 NPC전투장으로 분류됩니다.
</NumberInputWithInfo>
</div>
<div class="col">
<NumberInputWithInfo
v-model="nationPolicy.properWarTrainAtmos"
:step="5"
:min="20"
:max="100"
title="훈련/사기진작 목표치"
>
훈련/사기진작 기준치입니다. 이보다 같거나 높으면 출병합니다.
</NumberInputWithInfo>
</div>
<!--allowNpcAttackCity는 현재 게임 비활성-->
</div>
<div style="padding: 0 8pt">
<div class="alert alert-secondary">
전투 부대는 작업중입니다(json양식: {부대번호:[시작도시번호(아국),도착도시번호(적군)],...})
<br />후방 징병 부대는 작업중입니다(json양식: [부대번호,...]) <br />내정 부대는 작업중입니다(json양식:
[부대번호,...])
<input id="CombatForce" type="hidden" :value="JSON.stringify(nationPolicy.CombatForce)" data-type="json" />
<input id="SupportForce" type="hidden" :value="JSON.stringify(nationPolicy.SupportForce)" data-type="json" />
<input id="DevelopForce" type="hidden" :value="JSON.stringify(nationPolicy.DevelopForce)" data-type="json" />
</div>
</div>
<div class="control_bar" data-type="nationPolicy">
<div class="btn-group" role="group">
<button type="button" class="btn btn-dark reset_btn" @click="resetPolicy">초기값으로</button>
<button type="button" class="btn btn-secondary revert_btn" @click="rollbackPolicy">이전값으로</button>
</div>
<button type="button" class="btn btn-primary submit_btn" @click="submitPolicy">설정</button>
</div>
<div class="row row-cols-md-2 row-cols-1 g-0">
<div class="col half_section_left">
<div class="bg1 section_bar">NPC 사령턴 우선순위</div>
<div class="float-right px-3">
<small class="form-text text-muted">
최근 설정:
{{ lastSetters.nation.setter ?? "-없음-" }}
({{ lastSetters.nation.date ?? "설정 기록 없음" }})
</small>
</div>
<div class="text-left px-2">
<small class="text-muted">
예턴이 없거나, 지정되어 있더라도 실패하면
<br />아래 순위에 따라 사령턴을 시도합니다.
</small>
</div>
<div>
<div class="row g-0">
<div class="col">
<div class="bg2 sub_bar">비활성</div>
<draggable
:list="chiefActionUnused"
group="chiefAction"
class="list-group col priority-list"
itemKey="id"
>
<template #header>
<div class="list-group-item list-group-item-dark">&lt;비활성화 항목들&gt;</div>
</template>
<template #item="{ element }">
<div class="list-group-item">
<i class="bi bi-list" />
&nbsp;&nbsp;{{ element.id }}
<button
v-b-tooltip.hover
class="btn btn-sm float-right btn-secondary py-0 px-1"
:title="actionHelpText[element.id]"
>
<i class="bi bi-question-lg" />
</button>
</div>
</template>
</draggable>
</div>
<div class="col">
<div class="bg2 sub_bar">활성</div>
<draggable
:list="chiefActionPriority"
group="chiefAction"
class="list-group col priority-list"
itemKey="id"
>
<template #item="{ element }">
<div class="list-group-item">
<i class="bi bi-list" />
&nbsp;&nbsp;{{ element.id }}
<button
v-b-tooltip.hover
class="btn btn-sm float-right btn-secondary py-0 px-1"
:title="actionHelpText[element.id]"
>
<i class="bi bi-question-lg" />
</button>
</div>
</template>
</draggable>
</div>
</div>
<div class="control_bar" data-type="nationPriority">
<div class="btn-group" role="group">
<button type="button" class="btn btn-dark reset_btn" @click="resetNationPriority">초기값으로</button>
<button type="button" class="btn btn-secondary revert_btn" @click="rollbackNationPriority">
이전값으로
</button>
</div>
<button type="button" class="btn btn-primary submit_btn" @click="submitNationPriority">설정</button>
</div>
</div>
</div>
<div class="col half_section_right">
<div class="bg1 section_bar">NPC 일반턴 우선순위</div>
<div class="float-right px-3">
<small class="form-text text-muted">
최근 설정:
{{ lastSetters.general.setter ?? "-없음-" }}
({{ lastSetters.general.date ?? "설정 기록 없음" }})
</small>
</div>
<div class="text-left px-2">
<small class="text-muted">
순위가 높은 것부터 시도합니다.
<br />아무것도 실행할 없으면 물자조달이나 인재탐색을 합니다.
</small>
</div>
<div>
<div class="row g-0">
<div class="col">
<div class="bg2 sub_bar">비활성</div>
<draggable
:list="generalActionUnused"
group="generalAction"
class="list-group col priority-list"
itemKey="id"
>
<template #header>
<div class="list-group-item list-group-item-dark">&lt;비활성화 항목들&gt;</div>
</template>
<template #item="{ element }">
<div class="list-group-item">
<i class="bi bi-list" />
&nbsp;&nbsp;{{ element.id }}
<button
v-b-tooltip.hover
class="btn btn-sm float-right btn-secondary py-0 px-1"
:title="actionHelpText[element.id]"
>
<i class="bi bi-question-lg" />
</button>
</div>
</template>
</draggable>
</div>
<div class="col">
<div class="bg2 sub_bar">활성</div>
<draggable
:list="generalActionPriority"
group="generalAction"
class="list-group col priority-list"
itemKey="id"
>
<template #item="{ element }">
<div class="list-group-item">
<i class="bi bi-list" />
&nbsp;&nbsp;{{ element.id }}
<button
v-b-tooltip.hover
class="btn btn-sm float-right btn-secondary py-0 px-1"
:title="actionHelpText[element.id]"
>
<i class="bi bi-question-lg" />
</button>
</div>
</template>
</draggable>
</div>
</div>
<div class="control_bar" data-type="generalPriority">
<div class="btn-group" role="group">
<button type="button" class="btn btn-dark reset_btn" @click="resetGeneralPriority">초기값으로</button>
<button type="button" class="btn btn-secondary revert_btn" @click="rollbackGeneralPriority">
이전값으로
</button>
</div>
<button type="button" class="btn btn-primary submit_btn" @click="submitGeneralPriority">설정</button>
</div>
</div>
</div>
</div>
</BContainer>
</template>
<script lang="ts">
type SetterInfo = {
setter: string | null;
date: string | null;
};
declare const staticValues: {
nationID: number;
defaultNationPolicy: NationPolicy;
currentNationPolicy: NationPolicy;
zeroPolicy: NationPolicy;
currentNationPriority: NPCChiefActions[];
availableNationPriorityItems: NPCChiefActions[];
defaultNationPriority: NPCChiefActions[];
currentGeneralActionPriority: NPCGeneralActions[];
availableGeneralActionPriorityItems: NPCGeneralActions[];
defaultGeneralActionPriority: NPCGeneralActions[];
defaultStatNPCMax: number;
defaultStatMax: number;
lastSetters: {
policy: SetterInfo;
nation: SetterInfo;
general: SetterInfo;
};
};
</script>
<script setup lang="ts">
import "@scss/common/bootstrap5.scss";
import "@scss/game_bg.scss";
import { ref } from "vue";
import type { IDItem, InvalidResponse, NationPolicy, NPCChiefActions, NPCGeneralActions } from "@/defs";
import NumberInputWithInfo from "@/components/NumberInputWithInfo.vue";
import { cloneDeep, isEqual, isNumber, last } from "lodash";
import { unwrap } from "@util/unwrap";
import { convertFormData } from "@util/convertFormData";
import axios from "axios";
import { NPCPriorityBtnHelpMessage } from "@/helpTexts";
import draggable from "vuedraggable";
import TopBackBar from "@/components/TopBackBar.vue";
import { convertIDArray } from "@util/convertIDArray";
import { useToast, BContainer } from "bootstrap-vue-3";
const chiefActionPriority = ref<IDItem<NPCChiefActions>[]>([]);
const chiefActionKeys = ref(new Set(staticValues.availableNationPriorityItems));
for (const id of staticValues.currentNationPriority) {
chiefActionPriority.value.push({ id });
chiefActionKeys.value.delete(id);
}
const chiefActionUnused = ref<IDItem<NPCChiefActions>[]>(convertIDArray(chiefActionKeys.value));
const chiefActionStack = cloneDeep([chiefActionPriority.value]);
const generalActionPriority = ref<IDItem<NPCGeneralActions>[]>([]);
const generalActionKeys = ref(new Set(staticValues.availableGeneralActionPriorityItems));
for (const id of staticValues.currentGeneralActionPriority) {
generalActionPriority.value.push({ id });
generalActionKeys.value.delete(id);
}
const generalActionUnused = ref<IDItem<NPCGeneralActions>[]>(convertIDArray(generalActionKeys.value));
const generalActionStack = cloneDeep([generalActionPriority.value]);
const title = "NPC 정책";
const lastSetters = ref(staticValues.lastSetters);
const defaultStatMax = ref(staticValues.defaultStatMax);
const defaultStatNPCMax = ref(staticValues.defaultStatNPCMax);
const nationPolicy = ref(cloneDeep(staticValues.currentNationPolicy));
const nationPolicyStack = [staticValues.currentNationPolicy];
const zeroPolicy = ref(staticValues.zeroPolicy);
const actionHelpText = ref(NPCPriorityBtnHelpMessage);
const toasts = unwrap(useToast());
function resetPolicy() {
if (!confirm("초기 설정으로 되돌릴까요?")) {
return;
}
nationPolicy.value = cloneDeep(staticValues.defaultNationPolicy);
toasts.info({
title: "초기화 완료",
body: "서버 초기값을 적용했습니다.설정 버튼을 누르면 반영됩니다.",
});
}
function rollbackPolicy() {
if (!confirm("이전 설정으로 되돌릴까요?")) {
return;
}
let lastPolicy = unwrap(last(nationPolicyStack));
while (nationPolicyStack.length > 1) {
if (!isEqual(lastPolicy, nationPolicy.value)) {
break;
}
nationPolicyStack.pop();
lastPolicy = unwrap(last(nationPolicyStack));
}
nationPolicy.value = cloneDeep(lastPolicy);
toasts.info({
title: "되돌리기 완료",
body: "이전 설정으로 되돌렸습니다.",
});
}
async function submitPolicy() {
if (!confirm("저장할까요?")) {
return;
}
try {
const response = await axios({
url: "j_set_npc_control.php",
responseType: "json",
method: "post",
data: convertFormData({
type: "nationPolicy",
data: JSON.stringify(nationPolicy.value),
}),
});
const result: InvalidResponse = response.data;
if (!result.result) {
throw result.reason;
}
} catch (e) {
console.error(e);
toasts.danger({
title: "에러",
body: `설정하지 못했습니다: ${e}`,
});
return;
}
toasts.success({
title: "적용 완료",
body: "NPC 정책이 반영되었습니다.",
});
const lastPolicy = unwrap(last(nationPolicyStack));
if (!isEqual(lastPolicy, nationPolicy.value)) {
nationPolicyStack.push(cloneDeep(nationPolicy.value));
}
}
function calcPolicyValue(title: keyof NationPolicy): number {
if (!(title in nationPolicy.value)) {
throw `${title}이 NationPolicy key가 아님`;
}
const policyValue = nationPolicy.value[title];
if (!isNumber(policyValue)) {
throw `${title}에 해당하는 값이 number가 아님`;
}
if (policyValue == 0) {
return zeroPolicy.value[title] as number;
}
return policyValue;
}
function resetNationPriority() {
if (!confirm("초기 설정으로 되돌릴까요?")) {
return;
}
chiefActionUnused.value = [];
chiefActionPriority.value = staticValues.defaultNationPriority.map((id) => {
return { id };
});
toasts.info({
title: "초기화 완료",
body: "서버 초기값을 적용했습니다.설정 버튼을 누르면 반영됩니다.",
});
}
function rollbackNationPriority() {
if (!confirm("이전 설정으로 되돌릴까요?")) {
return;
}
let lastActions = unwrap(last(chiefActionStack));
while (chiefActionStack.length > 1) {
if (!isEqual(lastActions, chiefActionPriority.value)) {
break;
}
chiefActionStack.pop();
lastActions = unwrap(last(chiefActionStack));
}
const chiefActionKeys = new Set(staticValues.availableNationPriorityItems);
for (const { id } of lastActions) {
if (chiefActionKeys.has(id)) {
chiefActionKeys.delete(id);
}
}
chiefActionPriority.value = cloneDeep(lastActions);
chiefActionUnused.value = convertIDArray(chiefActionKeys);
toasts.info({
title: "되돌리기 완료",
body: "이전 설정으로 되돌렸습니다.",
});
}
async function submitNationPriority() {
if (!confirm("저장할까요?")) {
return;
}
try {
const response = await axios({
url: "j_set_npc_control.php",
responseType: "json",
method: "post",
data: convertFormData({
type: "nationPriority",
data: JSON.stringify(chiefActionPriority.value.map(({ id }) => id)),
}),
});
const result: InvalidResponse = response.data;
if (!result.result) {
throw result.reason;
}
} catch (e) {
console.error(e);
toasts.danger({
title: "에러",
body: `설정하지 못했습니다: ${e}`,
});
return;
}
toasts.success({
title: "적용 완료",
body: "NPC 정책이 반영되었습니다.",
});
const lastActions = unwrap(last(chiefActionStack));
if (!isEqual(lastActions, chiefActionPriority.value)) {
chiefActionStack.push(cloneDeep(chiefActionPriority.value));
}
}
function resetGeneralPriority() {
if (!confirm("초기 설정으로 되돌릴까요?")) {
return;
}
generalActionUnused.value = [];
generalActionPriority.value = staticValues.defaultGeneralActionPriority.map((id) => {
return { id };
});
toasts.info({
title: "초기화 완료",
body: "서버 초기값을 적용했습니다.설정 버튼을 누르면 반영됩니다.",
});
}
function rollbackGeneralPriority() {
if (!confirm("이전 설정으로 되돌릴까요?")) {
return;
}
let lastActions = unwrap(last(generalActionStack));
while (generalActionStack.length > 1) {
if (!isEqual(lastActions, generalActionPriority.value)) {
break;
}
generalActionStack.pop();
lastActions = unwrap(last(generalActionStack));
}
const generalActionKeys = new Set(staticValues.availableGeneralActionPriorityItems);
for (const { id } of lastActions) {
if (generalActionKeys.has(id)) {
generalActionKeys.delete(id);
}
}
generalActionPriority.value = cloneDeep(lastActions);
generalActionUnused.value = convertIDArray(generalActionKeys);
toasts.info({
title: "되돌리기 완료",
body: "이전 설정으로 되돌렸습니다.",
});
}
async function submitGeneralPriority() {
if (!confirm("저장할까요?")) {
return;
}
try {
const response = await axios({
url: "j_set_npc_control.php",
responseType: "json",
method: "post",
data: convertFormData({
type: "generalPriority",
data: JSON.stringify(generalActionPriority.value.map(({ id }) => id)),
}),
});
const result: InvalidResponse = response.data;
if (!result.result) {
throw result.reason;
}
} catch (e) {
console.error(e);
toasts.danger({
title: "에러",
body: `설정하지 못했습니다: ${e}`,
});
return;
}
toasts.success({
title: "적용 완료",
body: "NPC 정책이 반영되었습니다.",
});
const lastActions = unwrap(last(generalActionStack));
if (!isEqual(lastActions, generalActionPriority.value)) {
generalActionStack.push(cloneDeep(generalActionPriority.value));
}
}
</script>
<style>
.tooltip > .tooltip-inner {
max-width: 350px;
text-align: left;
}
.form_list {
margin: 8px;
}
.sub_bar {
text-align: center;
border: 0.5px solid #aaa;
margin-left: 5px;
margin-right: 5px;
}
.priority-list {
margin: 0 10px;
}
.control_bar {
padding: 0 8pt 8pt 8pt;
text-align: right;
}
.control_bar .btn {
margin-top: 8pt;
}
.reset_btn {
width: 15ch;
}
.revert_btn {
width: 15ch;
}
.submit_btn {
margin-left: 1em;
width: 15ch;
}
.half_section_left {
border-right: 0.5px solid #aaa;
}
.section_bar {
text-align: center;
border: 0.5px solid #aaa;
padding-right: 0;
padding-left: 0;
}
</style>
+78
View File
@@ -0,0 +1,78 @@
<template>
<BContainer id="container" :toast="{ root: true }" class="pageNationBetting bg0">
<TopBackBar :title="title" />
<BettingDetail v-if="targetBettingID !== undefined" :bettingID="targetBettingID" @reqToast="addToast" />
<div v-if="bettingList === undefined">로딩 중...</div>
<div v-else class="bettingList">
<div class="bg2">베팅 목록</div>
<div
v-for="info of Object.values(bettingList).reverse()"
:key="info.id"
class="bettingItem"
@click="targetBettingID = info.id"
>
[{{ parseYearMonth(info.openYearMonth)[0] }} {{ parseYearMonth(info.openYearMonth)[1] }}] {{ info.name }}
<span v-if="info.finished">(종료)</span>
<span v-else-if="(yearMonth ?? 0) <= info.closeYearMonth"
>({{ parseYearMonth(info.closeYearMonth)[0] }} {{ parseYearMonth(info.closeYearMonth)[1] }}월까지)</span
>
<span v-else>(베팅 마감)</span>
</div>
</div>
<BottomBar />
</BContainer>
</template>
<script lang="ts" setup>
import TopBackBar from "@/components/TopBackBar.vue";
import BottomBar from "@/components/BottomBar.vue";
import type { ToastType } from "@/defs";
import { onMounted, ref } from "vue";
import { SammoAPI } from "./SammoAPI";
import { isString } from "lodash";
import { parseYearMonth } from "@/util/parseYearMonth";
import { joinYearMonth } from "./util/joinYearMonth";
import BettingDetail from "@/components/BettingDetail.vue";
import { BContainer, useToast } from "bootstrap-vue-3";
import { unwrap } from "./util/unwrap";
import type { BettingListResponse } from "./defs/API/Betting";
const toasts = unwrap(useToast());
const year = ref<number>();
const month = ref<number>();
const yearMonth = ref<number>();
const bettingList = ref<BettingListResponse["bettingList"]>();
const targetBettingID = ref<number>();
function addToast(msg: ToastType) {
toasts.show(msg.content, msg.options);
}
console.log("시작!");
onMounted(async () => {
try {
const result = await SammoAPI.Betting.GetBettingList({
req: 'bettingNation'
});
year.value = result.year;
month.value = result.month;
yearMonth.value = joinYearMonth(result.year, result.month);
bettingList.value = result.bettingList;
console.log(result);
} catch (e) {
if (isString(e)) {
toasts.danger({
title: "에러",
body: e,
});
}
console.error(e);
}
});
const title = "국가 베팅장";
</script>
+121
View File
@@ -0,0 +1,121 @@
<template>
<BContainer id="container" :toast="{ root: true }" class="pageNationGeneral bg0">
<TopBackBar :title="title" :reloadable="true" :teleport-zone="toolbarID" @reload="reload" />
<!-- eslint-disable-next-line vue/max-attributes-per-line -->
<GeneralList
v-if="asyncReady"
:list="generalList"
:troops="troopList"
:env="envVal"
:toolbarID="toolbarID"
role="pageNationGeneral"
:height="'fill'"
:availableGeneralClick="true"
@generalClick="openBattleCenter"
/>
<!--<BottomBar />-->
</BContainer>
</template>
<script lang="ts">
/*declare const staticValues: {
serverNick: string,
mapName: string,
unitSet: string,
};*/
</script>
<script lang="ts" setup>
import TopBackBar from "@/components/TopBackBar.vue";
//import BottomBar from "@/components/BottomBar.vue";
import { BContainer } from "bootstrap-vue-3";
import { onMounted, provide, ref } from "vue";
import { SammoAPI } from "./SammoAPI";
import { merge2DArrToObjectArr } from "./util/merge2DArrToObjectArr";
import type { GeneralListItem, GeneralListResponse } from "./defs/API/Nation";
import GeneralList from "./components/GeneralList.vue";
import { type GameConstStore, getGameConstStore } from "./GameConstStore";
const generalList = ref<GeneralListItem[]>([]);
const troopList = ref<Record<number, string>>({});
const envVal = ref<GeneralListResponse["env"]>({
year: 1,
month: 1,
turnterm: 1,
turntime: "2022-02-22 22:22:22.22222",
killturn: 80,
});
const toolbarID = "toolbar-id";
const gameConstStore = ref<GameConstStore>();
provide("gameConstStore", gameConstStore);
const asyncReady = ref(false);
const title = "세력 장수";
const storeP = getGameConstStore().then((store) => {
gameConstStore.value = store;
});
void Promise.all([storeP]).then(() => {
asyncReady.value = true;
});
async function reload() {
try {
const { column, list, permission, troops, env } = await SammoAPI.Nation.GeneralList();
troopList.value = {};
//XXX: 로직상 똑같은데....
if (permission == 0) {
const rawGeneralList = merge2DArrToObjectArr(column, list);
generalList.value = rawGeneralList.map((v) => {
return { permission, st0: true, st1: false, st2: false, ...v };
});
} else if (permission == 1) {
const rawGeneralList = merge2DArrToObjectArr(column, list);
generalList.value = rawGeneralList.map((v) => {
return { permission, st0: true, st1: true, st2: false, ...v };
});
} else if ([2, 3, 4].includes(permission)) {
const rawGeneralList = merge2DArrToObjectArr(column, list);
generalList.value = rawGeneralList.map((v) => {
return { permission, st0: true, st1: true, st2: true, ...v };
});
for (const [troopLeader, troopName] of troops) {
troopList.value[troopLeader] = troopName;
}
} else {
throw `?? ${permission}`;
}
envVal.value = env;
} catch (e) {
console.error(e);
throw e;
}
}
function openBattleCenter(generalID: number){
window.open(`b_battleCenter.php?gen=${generalID}`)
}
onMounted(async () => {
await reload();
});
</script>
<style lang="scss">
html,
body,
#app {
height: 100%;
}
.pageNationGeneral {
display: grid;
//grid-template-rows: auto 1fr auto;
grid-template-rows: auto 1fr;
height: 100%;
}
</style>
+576
View File
@@ -0,0 +1,576 @@
<template>
<BContainer id="container" :toast="{ root: true }" class="pageNationStratFinan bg0">
<TopBackBar title="내무부" />
<div class="diplomacyTitle">외교관계</div>
<div class="diplomacyTable">
<div class="diplomacyHeader tRow bg1">
<div>국가명</div>
<div>국력</div>
<div>장수</div>
<div>속령</div>
<div>상태</div>
<div>기간</div>
<div>종료 시점</div>
</div>
<div v-for="nation in nationsList" :key="nation.nation" :class="['diplomacyItem', 'tRow', 's-border-b']">
<div :class="`sam-nation-bg-${nation.color.slice(1)}`">
{{ nation.name }}
</div>
<div>{{ nation.power.toLocaleString() }}</div>
<div>{{ nation.gennum.toLocaleString() }}</div>
<div>{{ nation.cityCnt.toLocaleString() }}</div>
<template v-if="nation.nation == nationID">
<div>-</div>
</template>
<template v-else>
<div
:style="{
color: diplomacyStateInfo[nation.diplomacy.state].color ?? undefined,
}"
>
{{ diplomacyStateInfo[nation.diplomacy.state].name }}
</div>
<div>
{{ nation.diplomacy.term == 0 ? "-" : `${nation.diplomacy.term}개월` }}
</div>
<div
v-for="([endYear, endMonth], _idx) in [
parseYearMonth(joinYearMonth(year, month) + (nation.diplomacy.term ?? 0)),
]"
:key="_idx"
>
{{ nation.diplomacy.term == 0 ? "-" : `${endYear}년 ${endMonth}월` }}
</div>
</template>
</div>
</div>
<div class="noticeTitle">국가 방침 &amp; 임관 권유 메시지</div>
<div id="noticeForm">
<div class="bg1" style="display: flex; justify-content: space-around">
<div style="flex: 1 1 auto">국가 방침</div>
<div>
<b-button v-if="editable && !inEditNationMsg" @click="enableEditNationMsg"> 국가방침 수정 </b-button>
<b-button v-if="editable && inEditNationMsg" @click="saveNationMsg"> 저장 </b-button>
<b-button v-if="editable && inEditNationMsg" @click="rollbackNationMsg"> 취소 </b-button>
</div>
</div>
<TipTap
v-model="nationMsg"
:editable="inEditNationMsg"
@ready="trackNationMsgHeight"
@update:modelValue="trackNationMsgHeight"
/>
</div>
<div id="scoutMsgForm">
<div class="bg1" style="display: flex; justify-content: space-around">
<div style="flex: 1 1 auto">임관 권유</div>
<div>
<b-button v-if="editable && !inEditScoutMsg" @click="enableEditScoutMsg"> 임관 권유문 수정 </b-button>
<b-button v-if="editable && inEditScoutMsg" @click="saveScoutMsg"> 저장 </b-button>
<b-button v-if="editable && inEditScoutMsg" @click="rollbackScoutMsg"> 취소 </b-button>
</div>
</div>
<div style="border-bottom: solid gray 0.5px">870px x 200px를 넘어서는 내용은 표시되지 않습니다.</div>
<div style="width: 870px; margin-left: auto">
<TipTap
v-model="scoutMsg"
:editable="inEditScoutMsg"
@ready="trackScoutMsgHeight"
@update:modelValue="trackScoutMsgHeight"
/>
</div>
</div>
<div class="financeTitle">예산&amp;정책</div>
<div class="row gx-0 center">
<div class="col-6">
<div class="row gx-0">
<div class="col-12 bg2">자금 예산</div>
<div class="col-4 bg1">현 재</div>
<div class="col-8">
{{ gold.toLocaleString() }}
</div>
<div class="col-4 bg1">단기수입</div>
<div class="col-8">
{{ income.gold.war.toLocaleString() }}
</div>
<div class="col-4 bg1">세 금</div>
<div class="col-8">
{{ Math.floor(incomeGoldCity).toLocaleString() }}
</div>
<div class="col-4 bg1">수입/지출</div>
<div class="col-8">
+{{ Math.floor(incomeGold).toLocaleString() }} /
{{ Math.floor(-outcomeByBill).toLocaleString() }}
</div>
<div class="col-4 bg1">국고 예산</div>
<div class="col-8">
{{ Math.floor(gold + incomeGold - outcomeByBill).toLocaleString() }}
({{ incomeGold >= outcomeByBill ? "+" : "" }}{{ Math.floor(incomeGold - outcomeByBill).toLocaleString() }})
</div>
</div>
</div>
<div class="col-6">
<div class="row gx-0">
<div class="col-12 bg2">군량 예산</div>
<div class="col-4 bg1">현 재</div>
<div class="col-8">
{{ rice.toLocaleString() }}
</div>
<div class="col-4 bg1">둔전수입</div>
<div class="col-8">
{{ Math.floor(incomeRiceWall).toLocaleString() }}
</div>
<div class="col-4 bg1">세 금</div>
<div class="col-8">
{{ Math.floor(incomeRiceCity).toLocaleString() }}
</div>
<div class="col-4 bg1">수입/지출</div>
<div class="col-8">
+{{ Math.floor(incomeRice).toLocaleString() }} /
{{ Math.floor(-outcomeByBill).toLocaleString() }}
</div>
<div class="col-4 bg1">국고 예산</div>
<div class="col-8">
{{ Math.floor(rice + incomeRice - outcomeByBill).toLocaleString() }}
({{ incomeRice >= outcomeByBill ? "+" : "" }}{{ Math.floor(incomeRice - outcomeByBill).toLocaleString() }})
</div>
</div>
</div>
<div class="col-6">
<div class="row gx-0">
<div class="col-4 bg1 d-grid">
<div class="align-self-center">세율 <span class="avoid-wrap">(5 ~ 30%)</span></div>
</div>
<div class="col-8 row gx-0">
<div class="col-md-6 offset-md-3 align-self-center">
<div class="input-group my-0">
<input
v-model="policy.rate"
type="number"
class="form-control py-1 f_tnum px-0 text-end"
min="5"
max="30"
/><span class="input-group-text py-1 f_tnum">%</span
><b-button v-if="editable" variant="primary" size="sm" @click="setRate"> 변경 </b-button
><b-button v-if="editable" size="sm" @click="rollbackRate"> 취소 </b-button>
</div>
</div>
</div>
</div>
</div>
<div class="col-6">
<div class="row gx-0">
<div class="col-4 bg1 d-grid">
<div class="align-self-center">지급률 <span class="avoid-wrap">(20 ~ 200%)</span></div>
</div>
<div class="col-8 row gx-0">
<div class="col-md-6 offset-md-3 align-self-center">
<div class="input-group my-0">
<input
v-model="policy.bill"
type="number"
class="form-control py-1 f_tnum px-0 text-end"
min="20"
max="200"
/><span class="input-group-text py-1 f_tnum">%</span
><b-button v-if="editable" variant="primary" size="sm" @click="setBill"> 변경 </b-button
><b-button v-if="editable" size="sm" @click="rollbackBill"> 취소 </b-button>
</div>
</div>
</div>
</div>
</div>
<div class="col-6">
<div class="row gx-0">
<div class="col-4 bg1 d-grid">
<div class="align-self-center">기밀 권한 <span class="avoid-wrap">(1 ~ 99년)</span></div>
</div>
<div class="col-8 row gx-0">
<div class="col-md-6 offset-md-3 align-self-center">
<div class="input-group my-0">
<input
v-model="policy.secretLimit"
type="number"
class="form-control py-1 f_tnum px-0 text-end"
min="1"
max="99"
/><span class="input-group-text py-1 f_tnum">년</span
><b-button v-if="editable" variant="primary" size="sm" @click="setSecretLimit"> 변경 </b-button
><b-button v-if="editable" size="sm" @click="rollbackSecretLimit"> 취소 </b-button>
</div>
</div>
</div>
</div>
</div>
<div class="col-6 d-grid">
<div class="row gx-0">
<div class="col-4 bg1 d-grid">
<div class="align-self-center">전쟁 금지 설정</div>
</div>
<div class="col-8 d-grid">
<div class="align-self-center">
{{ warSettingCnt.remain }} 회(월 +{{ warSettingCnt.inc }}회, 최대{{ warSettingCnt.max }}회)
</div>
</div>
</div>
</div>
<div class="col-3 col-md-4" />
<div class="col-3 col-md-2 row gx-0">
<div class="col-9 col-md-8 text-end p-2">전쟁 금지</div>
<div class="col-3 col-md-4 py-2">
<b-form-checkbox v-model="policy.blockWar" switch @change="setBlockWar" />
</div>
</div>
<div class="col-3 col-md-2 row gx-0">
<div class="col-9 col-md-8 text-end p-2">임관 금지</div>
<div class="col-3 col-md-4 py-2">
<b-form-checkbox v-model="policy.blockScout" switch @change="setBlockScout" />
</div>
</div>
</div>
<div>추가 설정</div>
<BottomBar title="내무부" />
</BContainer>
</template>
<script lang="ts">
import TipTap from "./components/TipTap.vue";
import TopBackBar from "@/components/TopBackBar.vue";
import BottomBar from "@/components/BottomBar.vue";
import { computed, defineComponent, reactive, ref, toRefs } from "vue";
import { isString } from "lodash";
import { type diplomacyState, diplomacyStateInfo, type NationStaticItem } from "./defs";
import { SammoAPI } from "./SammoAPI";
import { joinYearMonth } from "@/util/joinYearMonth";
import { parseYearMonth } from "@/util/parseYearMonth";
import { useToast, BContainer } from "bootstrap-vue-3";
import { unwrap } from "./util/unwrap";
type NationItem = NationStaticItem & {
cityCnt: number;
diplomacy: {
state: diplomacyState;
term: number | null;
};
};
declare const staticValues: {
editable: boolean;
nationMsg: string;
scoutMsg: string;
nationID: number;
officerLevel: number;
year: number;
month: number;
nationsList: Record<number, NationItem>;
gold: number;
rice: number;
income: {
gold: {
city: number;
war: number;
};
rice: {
city: number;
wall: number;
};
};
outcome: number;
policy: {
rate: number;
bill: number;
secretLimit: number;
blockScout: boolean;
blockWar: boolean;
};
warSettingCnt: {
remain: number;
inc: number;
max: number;
};
};
export default defineComponent({
components: {
TopBackBar,
BottomBar,
TipTap,
BContainer,
},
setup() {
const toasts = unwrap(useToast());
const self = reactive(staticValues);
let oldNationMsg = staticValues.nationMsg;
const inEditNationMsg = ref(false);
function enableEditNationMsg() {
inEditNationMsg.value = true;
}
function rollbackNationMsg() {
inEditNationMsg.value = false;
self.nationMsg = oldNationMsg;
}
async function saveNationMsg() {
const msg = self.nationMsg;
try {
await SammoAPI.Nation.SetNotice({
msg,
});
oldNationMsg = msg;
inEditNationMsg.value = false;
toasts.info({
title: "변경",
body: "국가 방침을 변경했습니다.",
});
} catch (e) {
if (isString(e)) {
toasts.danger({
title: "에러",
body: e,
});
}
console.error(e);
}
}
let oldScoutMsg = staticValues.scoutMsg;
const inEditScoutMsg = ref(false);
function enableEditScoutMsg() {
inEditScoutMsg.value = true;
}
function rollbackScoutMsg() {
inEditScoutMsg.value = false;
self.scoutMsg = oldScoutMsg;
}
async function saveScoutMsg() {
const msg = self.scoutMsg;
try {
await SammoAPI.Nation.SetScoutMsg({
msg,
});
oldScoutMsg = msg;
inEditScoutMsg.value = false;
toasts.info({
title: "변경",
body: "임관 권유문을 변경했습니다.",
});
} catch (e) {
if (isString(e)) {
toasts.danger({
title: "에러",
body: e,
});
}
console.error(e);
}
}
const trackTiptapFormHeight = (target: string) => {
let form: HTMLElement | null = null;
let outerForm: HTMLElement | null = null;
function handler() {
if (!form) {
form = document.querySelector(`${target} .ProseMirror`);
}
if (!outerForm) {
outerForm = document.querySelector(`${target} .tiptap-editor`);
}
if (!form || !outerForm) {
return;
}
const { height: clientHeight } = form.getBoundingClientRect();
const { height: parentHeight } = outerForm.getBoundingClientRect();
if (parentHeight != clientHeight) {
outerForm.style.height = `${clientHeight}px`;
}
}
window.addEventListener("orientationchange", handler, true);
return handler;
};
const incomeGoldCity = computed(() => {
return (self.income.gold.city * self.policy.rate) / 100;
});
const incomeGold = computed(() => {
return incomeGoldCity.value + self.income.gold.war;
});
const incomeRiceCity = computed(() => {
return (self.income.rice.city * self.policy.rate) / 100;
});
const incomeRiceWall = computed(() => {
return (self.income.rice.wall * self.policy.rate) / 100;
});
const incomeRice = computed(() => {
return incomeRiceCity.value + incomeRiceWall.value;
});
const outcomeByBill = computed(() => {
return (self.outcome * self.policy.bill) / 100;
});
let oldRate = staticValues.policy.rate;
async function setRate() {
const rate = self.policy.rate;
try {
await SammoAPI.Nation.SetRate({ amount: rate });
oldRate = rate;
toasts.info({
title: "변경",
body: "세율을 변경했습니다.",
});
} catch (e) {
if (isString(e)) {
toasts.danger({
title: "에러",
body: e,
});
}
console.error(e);
}
}
function rollbackRate() {
self.policy.rate = oldRate;
}
let oldBill = staticValues.policy.bill;
async function setBill() {
const bill = self.policy.bill;
try {
await SammoAPI.Nation.SetBill({ amount: bill });
oldBill = bill;
toasts.info({
title: "변경",
body: "지급률을 변경했습니다.",
});
} catch (e) {
if (isString(e)) {
toasts.danger({
title: "에러",
body: e,
});
}
console.error(e);
}
}
function rollbackBill() {
self.policy.bill = oldBill;
}
let oldSecretLimit = staticValues.policy.secretLimit;
async function setSecretLimit() {
const secretLimit = self.policy.secretLimit;
try {
await SammoAPI.Nation.SetSecretLimit({ amount: secretLimit });
oldSecretLimit = secretLimit;
toasts.info({
title: "변경",
body: "기밀 권한을 변경했습니다.",
});
} catch (e) {
if (isString(e)) {
toasts.danger({
title: "에러",
body: e,
});
}
self.policy.secretLimit = oldSecretLimit;
console.error(e);
}
}
function rollbackSecretLimit() {
self.policy.secretLimit = oldSecretLimit;
}
async function setBlockWar() {
try {
const result = await SammoAPI.Nation.SetBlockWar({ value: self.policy.blockWar });
self.warSettingCnt.remain = result.availableCnt;
toasts.info({
title: "변경",
body: "전쟁 금지 설정을 변경했습니다.",
});
} catch (e) {
if (isString(e)) {
toasts.danger({
title: "에러",
body: e,
});
}
self.policy.blockWar = !self.policy.blockWar;
console.error(e);
}
}
async function setBlockScout() {
try {
await SammoAPI.Nation.SetBlockScout({ value: self.policy.blockScout });
toasts.info({
title: "변경",
body: "임관 설정을 변경했습니다.",
});
} catch (e) {
if (isString(e)) {
toasts.danger({
title: "에러",
body: e,
});
}
self.policy.blockScout = !self.policy.blockScout;
console.error(e);
}
}
return {
toasts,
...toRefs(self),
inEditNationMsg,
inEditScoutMsg,
enableEditNationMsg,
rollbackNationMsg,
saveNationMsg,
enableEditScoutMsg,
rollbackScoutMsg,
saveScoutMsg,
diplomacyStateInfo,
joinYearMonth,
parseYearMonth,
trackNationMsgHeight: trackTiptapFormHeight("#noticeForm"),
trackScoutMsgHeight: trackTiptapFormHeight("#scoutMsgForm"),
incomeGoldCity,
incomeGold,
incomeRiceCity,
incomeRiceWall,
incomeRice,
outcomeByBill,
setRate,
rollbackRate,
setBill,
rollbackBill,
setSecretLimit,
rollbackSecretLimit,
setBlockWar,
setBlockScout,
};
},
methods: {},
});
</script>
+531
View File
@@ -0,0 +1,531 @@
<template>
<BContainer id="container" :toast="{ root: true }" class="pageVote bg0">
<TopBackBar type="close" :reloadable="true" title="" @reload="reloadVote" />
<div id="vote-title" class="bg2">설문 조사({{ voteReward }}금과 추첨으로 유니크템 증정!)</div>
<table v-if="currentVote" id="vote-result">
<colgroup>
<col class="vote-idx" />
<col class="vote-count" />
<col class="vote-percent" />
<col class="vote-option" />
</colgroup>
<thead>
<tr>
<th colspan="3" class="text-end bg1">설문 제목</th>
<th id="vote-detail-title">
{{ currentVote.voteInfo.title }}
<template v-if="currentVote.voteInfo.multipleOptions !== 1"
>(
{{
currentVote.voteInfo.multipleOptions == 0
? currentVote.voteInfo.options.length
: currentVote.voteInfo.multipleOptions
}} 선택 가능 )</template
>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(option, idx) in currentVote.voteInfo.options" :key="idx">
<template v-if="canVote">
<td v-if="currentVote.voteInfo.multipleOptions == 1" class="text-center">
<input :id="`v-vote-${idx}`" v-model="mySinglePick" class="form-check-input" type="radio" :value="idx" />
</td>
<td v-else class="text-center">
<input
:id="`v-vote-${idx}`"
v-model="myMultiPick"
class="form-check-input"
type="checkbox"
:value="idx"
/>
</td>
</template>
<td
v-else
class="text-end f_tnum"
:style="{
backgroundColor: formatVoteColor(idx),
color: isBrightColor(formatVoteColor(idx)) ? '#000' : '#fff',
}"
>
{{ idx + 1 }}.
</td>
<td class="text-end f_tnum vote-count">
<label :for="`v-vote-${idx}`">{{ voteDistribution[idx] }}</label>
</td>
<td class="text-end f_tnum vote-percent">
<label :for="`v-vote-${idx}`"
>({{ ((voteDistribution[idx] / Math.max(1, voteTotal)) * 100).toFixed(1) }}%)</label
>
</td>
<td>
<label :for="`v-vote-${idx}`">{{ option }}</label>
</td>
</tr>
</tbody>
<tfoot>
<tr>
<template v-if="canVote">
<td class="text-center">투표</td>
<td colspan="2">
<div class="d-grid"><BButton @click="submitVote">투표</BButton></div>
</td>
</template>
<td v-else colspan="3" class="text-center">결산</td>
<td>
투표율: {{ voteTotal }} / {{ currentVote.userCnt }} (
{{ ((voteTotal / Math.max(1, currentVote.userCnt)) * 100).toFixed(1) }}%)
</td>
<td></td>
</tr>
</tfoot>
</table>
<form @submit.prevent="submitComment">
<table v-if="currentVote" id="vote-comment">
<colgroup>
<col class="comment-idx" />
<col class="comment-name" />
<col class="comment-text" />
<col class="comment-date" />
</colgroup>
<thead>
<tr class="bg1 text-center">
<th>#</th>
<th>
<div class="row gx-0">
<div class="col-12 col-md-6">국가명</div>
<div class="col-12 col-md-6">장수명</div>
</div>
</th>
<th>댓글</th>
<th>일시</th>
</tr>
</thead>
<tbody>
<tr v-for="(comment, idx) in currentVote.comments" :key="idx">
<td class="comment-idx f_tnum">{{ idx + 1 }}.</td>
<td class="comment-name">
<div class="row gx-0">
<div class="col-12 col-md-6">{{ comment.nationName }}</div>
<div class="col-12 col-md-6">{{ comment.generalName }}</div>
</div>
</td>
<td>{{ comment.text }}</td>
<td class="comment-date f_tnum">{{ comment.date.substring(5, 5 + 5 + 1 + 5) }}</td>
</tr>
</tbody>
<tfoot>
<tr>
<td></td>
<td>
<div class="offset-md-6 d-grid"><BButton type="submit">댓글 달기</BButton></div>
</td>
<td colspan="2"><BFormInput v-model="myComment" /></td>
</tr>
</tfoot>
</table>
</form>
<div id="vote-old-title" class="bg2">이전 설문 조사</div>
<div id="vote-old-list">
<div v-for="[voteID, voteInfo] of voteList" :key="voteID" class="vote-old-item">
<div class="row">
<div class="col">
<a href="#" @click.prevent="currentVoteID = voteID">{{ voteInfo.title }}</a> ({{ voteInfo.startDate }})
</div>
</div>
</div>
</div>
<div v-if="isVoteAdmin" id="vote-new-panel">
<div class="row"><a href="#" @click.prevent="showNewVote = !showNewVote"> 설문 조사 열기</a></div>
<template v-if="showNewVote">
<div class="row gx-0">
<div class="col-md-3">설문 제목</div>
<div class="col-md-9"><BFormInput v-model="newVoteInfo.title" type="text" /></div>
</div>
<div class="row gx-0">
<div class="col-md-3">설문 대상(엔터로 구분) ({{ newVoteInfo.options.length }})</div>
<div class="col-md-9">
<textarea v-model="newVoteOptionsText" class="form-control" :rows="newVoteOptionsLength + 1"></textarea>
</div>
</div>
<div class="row gx-0">
<div class="col-md-3">동시 응답 (0=모두)</div>
<div class="col-md-9">
<BFormInput
v-model.number="newVoteInfo.multipleOptions"
type="number"
:min="0"
:max="newVoteInfo.options.length"
/>
</div>
</div>
<div class="row gx-0">
<div class="offset-8 col-4 offset-md-10 col-md-2 d-grid">
<BButton @click="submitNewVote">제출</BButton>
</div>
</div>
</template>
</div>
<BottomBar type="close" />
</BContainer>
</template>
<script lang="ts">
declare const staticValues: {
serverNick: string;
serverID: string;
isGameLoggedIn: boolean;
isVoteAdmin: boolean;
voteReward: number;
};
</script>
<script lang="ts" setup>
import TopBackBar from "@/components/TopBackBar.vue";
import BottomBar from "@/components/BottomBar.vue";
import { BContainer, useToast, BButton, BFormInput } from "bootstrap-vue-3";
import { unwrap } from "@/util/unwrap";
import { onMounted, reactive, ref, watch, computed } from "vue";
import type { VoteInfo, VoteDetailResult } from "@/defs/API/Vote";
import { SammoAPI } from "@/SammoAPI";
import { isString, range, sum } from "lodash";
import { formatTime } from "@/util/formatTime";
import { isBrightColor } from "@/util/isBrightColor";
import { formatVoteColor } from "@/utilGame/formatVoteColor";
const { isVoteAdmin, isGameLoggedIn, voteReward } = staticValues;
const voteList = ref(new Map<number, VoteInfo>());
const toasts = unwrap(useToast());
const showNewVote = ref(false);
const voteTotal = ref(0);
const voteDistribution = ref<Record<number, number>>({});
const currentVote = ref<VoteDetailResult>();
watch(currentVote, (voteResult) => {
if (!voteResult) {
return;
}
const voteDist: Record<number, number> = {};
for (const idx of range(voteResult.voteInfo.options.length)) {
voteDist[idx] = 0;
}
voteTotal.value = sum(voteResult.votes.map(([, count]) => count));
for (const [selection, count] of voteResult.votes) {
for (const idx of selection) {
voteDist[idx] += count;
}
}
voteDistribution.value = voteDist;
});
const canVote = computed(() => {
if (!isGameLoggedIn) {
return false;
}
if (!currentVote.value) {
return false;
}
if (currentVote.value.myVote) {
return false;
}
const endDate = currentVote.value.voteInfo.endDate;
if (endDate) {
const now = formatTime(new Date());
if (now > endDate) {
return false;
}
}
return true;
});
const currentVoteID = ref<number>();
watch(currentVoteID, async (voteID) => {
if (voteID === undefined) {
return;
}
await reloadVoteDetail(voteID);
});
const mySinglePick = ref(0);
const myMultiPick = ref<number[]>([]);
watch(myMultiPick, (newPick, oldPick) => {
if (currentVote.value === undefined) {
return;
}
const multipleOptions = currentVote.value.voteInfo.multipleOptions;
if (multipleOptions == 0) {
return;
}
if (newPick.length <= multipleOptions) {
return;
}
toasts.warning({
title: "오류",
body: `${multipleOptions}개까지만 선택할 수 있습니다.`,
});
myMultiPick.value = oldPick;
});
async function submitVote() {
if (currentVote.value === undefined) {
return;
}
const selection = currentVote.value.voteInfo.multipleOptions !== 1 ? myMultiPick.value : [mySinglePick.value];
if (selection.length === 0) {
toasts.danger({
title: "오류",
body: "선택한 항목이 없습니다.",
});
return;
}
const voteID = currentVote.value.voteInfo.id;
try {
const result = await SammoAPI.Vote.Vote({
voteID,
selection,
});
toasts.success({
title: "성공",
body: "설문을 마쳤습니다.",
});
if (result.wonLottery) {
toasts.info({
title: "성공",
body: "특별한 설문 보상이 제공되었습니다!",
});
}
void reloadVoteDetail(voteID);
} catch (e) {
console.error(e);
if (isString(e)) {
toasts.danger({
title: "에러",
body: e,
});
}
}
}
const myComment = ref("");
async function submitComment() {
if (currentVote.value === undefined) {
return;
}
const voteID = currentVote.value.voteInfo.id;
const text = myComment.value;
try {
await SammoAPI.Vote.AddComment({
voteID,
text,
});
toasts.success({
title: "성공",
body: "댓글을 달았습니다.",
});
myComment.value = "";
void reloadVoteDetail(voteID);
} catch (e) {
console.error(e);
if (isString(e)) {
toasts.danger({
title: "에러",
body: e,
});
}
}
}
const newVoteInfo = reactive<Omit<VoteInfo, "id" | "startDate">>({
title: "",
multipleOptions: 1,
endDate: undefined,
options: [],
});
const newVoteOptionsLength = ref(0);
const newVoteOptionsText = ref("");
watch(newVoteOptionsText, (newValue) => {
const lines = newValue.split("\n");
newVoteInfo.options = lines.filter((v) => v.length > 0);
newVoteOptionsLength.value = lines.length;
});
async function reloadVoteDetail(voteID: number) {
try {
const result = await SammoAPI.Vote.GetVoteDetail({ voteID });
currentVote.value = result;
if (voteID !== currentVoteID.value) {
myMultiPick.value = [];
mySinglePick.value = 0;
myComment.value = "";
}
} catch (e) {
console.error(e);
if (isString(e)) {
toasts.danger({
title: "에러",
body: e,
});
}
}
}
async function reloadVote() {
try {
const result = await SammoAPI.Vote.GetVoteList();
const newVoteList = new Map<number, VoteInfo>();
for (const [rawVoteID, info] of Object.entries(result.votes).reverse()) {
newVoteList.set(parseInt(rawVoteID), info);
}
voteList.value = newVoteList;
if (newVoteList.size > 0 && currentVoteID.value === undefined) {
currentVoteID.value = newVoteList.keys().next().value;
} else if (currentVoteID.value !== undefined) {
void reloadVoteDetail(currentVoteID.value);
}
} catch (e) {
console.error(e);
if (isString(e)) {
toasts.danger({
title: "에러",
body: e,
});
}
}
}
onMounted(function () {
void reloadVote();
});
async function submitNewVote() {
try {
await SammoAPI.Vote.NewVote(newVoteInfo);
toasts.success({
title: "성공",
body: "설문 조사가 생성되었습니다.",
});
void reloadVote();
} catch (e) {
console.error(e);
if (isString(e)) {
toasts.danger({
title: "에러",
body: e,
});
}
}
}
</script>
<style lang="scss" scoped>
@import "@scss/common/break_500px.scss";
#vote-title {
font-size: 1.8em;
text-align: center;
}
#vote-old-title {
font-size: 1.5em;
text-align: center;
}
#vote-result {
width: 100%;
th,
td {
padding-left: 1ch;
padding-right: 1ch;
}
label {
display: block;
}
.vote-idx {
width: 5ch;
}
.vote-count {
width: 55px;
padding-right: 0;
}
.vote-percent {
width: 70px;
padding-left: 0;
}
}
#vote-comment {
width: 100%;
th,
td {
padding-left: 1ch;
padding-right: 1ch;
}
.comment-idx {
width: 5ch;
text-align: end;
}
.comment-name {
width: 110px;
text-align: center;
}
tbody tr {
border-top: solid gray 1px;
}
}
@include media-1000px {
#vote-comment {
.comment-name {
width: 260px;
}
.comment-date {
width: 98px;
padding-left: 0.5ch;
padding-right: 0.5ch;
text-align: center;
}
}
}
@include media-500px {
#vote-comment {
.comment-name {
width: 130px;
}
.comment-date {
width: 50px;
padding-left: 0.5ch;
padding-right: 0.5ch;
text-align: center;
}
}
}
</style>
+895
View File
@@ -0,0 +1,895 @@
<template>
<div class="commandPad">
<div class="col alert alert-dark m-0 p-1 center">
<h4 class="m-0">명령 목록</h4>
</div>
<div class="row gx-1">
<div class="col d-grid">
<BButton variant="secondary" @click="isEditMode = !isEditMode">
{{ isEditMode ? "일반 모드로" : "고급 모드로" }}
</BButton>
</div>
<div
class="col alert alert-primary m-0 p-0"
style="text-align: center; display: flex; justify-content: center; align-items: center"
>
<SimpleClock :serverTime="serverNow" />
</div>
<div class="col d-grid">
<BDropdown right text="반복">
<BDropdownItem v-for="turnIdx in maxPushTurn" :key="turnIdx" @click="repeatGeneralCommand(turnIdx)">
{{ turnIdx }}
</BDropdownItem>
</BDropdown>
</div>
</div>
<div v-if="isEditMode" class="row gx-1">
<div class="col-4 d-grid">
<BDropdown left text="범위">
<BDropdownItem @click="queryActionHelper.selectTurn()"> 해제 </BDropdownItem>
<BDropdownItem @click="queryActionHelper.selectAll()"> 모든턴 </BDropdownItem>
<BDropdownItem @click="queryActionHelper.selectStep(0, 2)"> 홀수턴 </BDropdownItem>
<BDropdownItem @click="queryActionHelper.selectStep(1, 2)"> 짝수턴 </BDropdownItem>
<BDropdownDivider />
<BDropdownText v-for="spanIdx in [3, 4, 5, 6, 7]" :key="spanIdx">
{{ spanIdx }} 간격
<br />
<BButtonGroup>
<BButton
v-for="beginIdx in spanIdx"
:key="beginIdx"
class="ignoreMe"
@click="queryActionHelper.selectStep(beginIdx - 1, spanIdx)"
>
{{ beginIdx }}
</BButton>
</BButtonGroup>
</BDropdownText>
</BDropdown>
</div>
<div class="col-4 d-grid">
<BDropdown left text="보관함">
<BDropdownItem
v-for="[actionKey, actions] of storedActions"
:key="actionKey"
@click.self="useStoredAction(actions)"
>
{{ actionKey }}
<BButton size="sm" @click.prevent="deleteStoredActions(actionKey)"> 삭제 </BButton>
</BDropdownItem>
</BDropdown>
</div>
<div class="col-4 d-grid">
<BDropdown right text="최근 실행">
<BDropdownItem
v-for="(action, idx) of Array.from(recentActions.values()).reverse()"
:key="idx"
@click="void reserveCommandDirect([[queryActionHelper.getSelectedTurnList(), action]])"
>
{{ action.brief }}
</BDropdownItem>
</BDropdown>
</div>
<div class="col-5 d-grid">
<BDropdown left variant="light" :style="{ color: 'black' }" text="선택한 턴을">
<BDropdownItem @click="clipboardCut"> <i class="bi bi-scissors" />&nbsp;잘라내기 </BDropdownItem>
<BDropdownItem @click="clipboardCopy"> <i class="bi bi-files" />&nbsp;복사하기 </BDropdownItem>
<BDropdownItem @click="clipboardPaste"> <i class="bi bi-clipboard-fill" />&nbsp;붙여넣기 </BDropdownItem>
<BDropdownDivider />
<BDropdownItem @click="setStoredActions">
<i class="bi bi-bookmark-plus-fill" />&nbsp;보관하기
</BDropdownItem>
<BDropdownItem @click="subRepeatCommand"> <i class="bi bi-arrow-repeat" />&nbsp;반복하기 </BDropdownItem>
<BDropdownDivider />
<BDropdownItem @click="eraseSelectedTurnList"> <i class="bi bi-eraser" />&nbsp;비우기 </BDropdownItem>
<BDropdownItem @click="eraseAndPullCommand">
<i class="bi bi-arrow-bar-up" />&nbsp;지우고 당기기
</BDropdownItem>
<BDropdownItem @click="pushEmptyCommand"> <i class="bi bi-arrow-bar-down" />&nbsp;뒤로 밀기 </BDropdownItem>
<!-- 최근에 실행한 10 -->
</BDropdown>
</div>
<div class="col-7 d-grid">
<BButton variant="info" @click="toggleForm($event)"> 명령 선택 </BButton>
</div>
</div>
<CommandSelectForm
ref="commandSelectForm"
v-model:activatedCategory="activatedCategory"
:commandList="commandList"
@onClose="chooseCommand($event)"
/>
<div :style="{ position: 'relative' }">
<div
class="bg-dark"
:style="{
position: 'absolute',
top: `${basicModeRowHeight * currentQuickReserveTarget + 30}px`,
width: '100%',
zIndex: 9,
}"
>
<CommandSelectForm
ref="commandQuickReserveForm"
v-model:activatedCategory="activatedCategory"
:commandList="commandList"
:hideClose="false"
@onClose="chooseQuickReserveCommand($event)"
/>
</div>
</div>
<div
:class="{
commandTable: true,
isEditMode,
}"
>
<DragSelect
v-slot="{ selected }"
:style="rowGridStyle"
:disabled="!isEditMode"
attribute="turnIdx"
@dragStart="isDragToggle = true"
@dragDone="
isDragToggle = false;
queryActionHelper.toggleTurn(...$event);
"
>
<div
v-for="(turnObj, turnIdx) in reservedCommandList.slice(0, viewMaxTurn)"
:key="turnIdx"
:turnIdx="turnIdx"
class="idx_pad center d-grid"
>
<BButton
v-if="isEditMode"
size="sm"
:variant="
isDragToggle && selected.has(`${turnIdx}`)
? 'light'
: selectedTurnList.has(turnIdx)
? 'info'
: selectedTurnList.size == 0 && prevSelectedTurnList.has(turnIdx)
? 'success'
: 'primary'
"
>
{{ turnIdx + 1 }}
</BButton>
<div v-else class="plain-center">
{{ turnIdx + 1 }}
</div>
</div>
</DragSelect>
<DragSelect
v-slot="{ selected }"
:style="rowGridStyle"
attribute="turnIdx"
:disabled="!isEditMode"
@dragStart="isDragSingle = true"
@dragDone="
isDragSingle = false;
queryActionHelper.selectTurn(...$event);
"
>
<div
v-for="(turnObj, turnIdx) in reservedCommandList.slice(0, viewMaxTurn)"
:key="turnIdx"
height="24"
class="month_pad center"
:turnIdx="turnIdx"
:style="{
'white-space': 'nowrap',
'font-size': `${Math.min(14, (75 / (`${turnObj.year ?? 1}`.length + 8)) * 1.8)}px`,
overflow: 'hidden',
color: isDragSingle && selected.has(`${turnIdx}`) ? 'cyan' : undefined,
}"
>
{{ turnObj.year ? `${turnObj.year}` : "" }}
{{ turnObj.month ? `${turnObj.month}` : "" }}
</div>
</DragSelect>
<div :style="rowGridStyle">
<div
v-for="(turnObj, turnIdx) in reservedCommandList.slice(0, viewMaxTurn)"
:key="turnIdx"
class="time_pad center"
:style="{
backgroundColor: 'black',
whiteSpace: 'nowrap',
overflow: 'hidden',
}"
>
{{ turnObj.time }}
</div>
</div>
<div :style="rowGridStyle">
<div
v-for="(turnObj, turnIdx) in reservedCommandList.slice(0, viewMaxTurn)"
:key="turnIdx"
class="turn_pad center"
>
<span v-b-tooltip.hover class="turn_text" :style="turnObj.style" :title="turnObj.tooltip">
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-html="turnObj.brief" />
</span>
</div>
</div>
<div v-if="!isEditMode" :style="rowGridStyle">
<div
v-for="(turnObj, turnIdx) in reservedCommandList.slice(0, viewMaxTurn)"
:key="turnIdx"
class="action_pad d-grid"
>
<BButton
:variant="turnIdx % 2 == 0 ? 'secondary' : 'dark'"
size="sm"
class="simple_action_btn bi bi-pencil"
@click="toggleQuickReserveForm(turnIdx)"
/>
</div>
</div>
</div>
<div class="row gx-1">
<div class="col d-grid">
<BDropdown :split="isEditMode" text="당기기" @click="pullGeneralCommandSingle">
<BDropdownItem v-for="turnIdx in maxPushTurn" :key="turnIdx" @click="pushGeneralCommand(-turnIdx)">
{{ turnIdx }}
</BDropdownItem>
</BDropdown>
</div>
<div class="col d-grid">
<BDropdown :split="isEditMode" text="미루기" @click="pushGeneralCommandSingle">
<BDropdownItem v-for="turnIdx in maxPushTurn" :key="turnIdx" @click="pushGeneralCommand(turnIdx)">
{{ turnIdx }}
</BDropdownItem>
</BDropdown>
</div>
<div class="col d-grid">
<BButton @click="toggleViewMaxTurn">
{{ flippedMaxTurn == viewMaxTurn ? "펼치기" : "접기" }}
</BButton>
</div>
</div>
</div>
</template>
<script lang="ts">
declare const staticValues: {
maxTurn: number;
maxPushTurn: number;
commandList: {
category: string;
values: CommandItem[];
}[];
serverNow: string;
serverNick: string;
mapName: string;
unitSet: string;
};
</script>
<script lang="ts" setup>
import addMinutes from "date-fns/esm/addMinutes";
import { range, trim } from "lodash";
import { stringifyUrl } from "query-string";
import { onMounted, ref, watch } from "vue";
import { formatTime } from "@util/formatTime";
import { joinYearMonth } from "@util/joinYearMonth";
import { mb_strwidth } from "@util/mb_strwidth";
import { parseTime } from "@util/parseTime";
import { parseYearMonth } from "@util/parseYearMonth";
import DragSelect from "@/components/DragSelect.vue";
import { SammoAPI } from "./SammoAPI";
import type { CommandItem } from "@/defs";
import CommandSelectForm from "@/components/CommandSelectForm.vue";
import { BButton, BButtonGroup, BDropdownItem, BDropdown, BDropdownText, BDropdownDivider } from "bootstrap-vue-3";
import { StoredActionsHelper } from "./util/StoredActionsHelper";
import type { TurnObj } from "@/defs";
import type { Args } from "./processing/args";
import { QueryActionHelper } from "./util/QueryActionHelper";
import SimpleClock from "./components/SimpleClock.vue";
import type { ReservedCommandResponse } from "./defs/API/Command";
const { maxTurn, maxPushTurn, commandList } = staticValues;
const listReqArgCommand = new Set<string>();
const selectedCommand = ref(staticValues.commandList[0].values[0]);
const commandSelectForm = ref<InstanceType<typeof CommandSelectForm> | null>(null);
for (const commandCategories of commandList) {
if (!commandCategories.values) {
continue;
}
for (const commandObj of commandCategories.values) {
if (!commandObj.reqArg) {
continue;
}
listReqArgCommand.add(commandObj.value);
}
}
function toggleForm($event: Event): void {
$event.preventDefault();
const form = commandSelectForm.value;
if (!form) {
return;
}
form.toggle();
}
function isDropdownChildren(e?: Event): boolean {
if (!e) {
return false;
}
if (!e.target) {
return false;
}
if (
(e.target as HTMLElement).classList.contains("dropdown-item") ||
(e.target as HTMLElement).classList.contains("dropdown-toggle-split") ||
(e.target as HTMLElement).classList.contains("ignoreMe")
) {
return true;
}
return false;
}
const queryActionHelper = new QueryActionHelper(staticValues.maxTurn);
const storedActionsHelper = new StoredActionsHelper(
staticValues.serverNick,
"general",
staticValues.mapName,
staticValues.unitSet
);
const reservedCommandList = queryActionHelper.reservedCommandList;
const prevSelectedTurnList = queryActionHelper.prevSelectedTurnList;
const selectedTurnList = queryActionHelper.selectedTurnList;
const isEditMode = storedActionsHelper.isEditMode;
const flippedMaxTurn = 14;
const editModeRowHeight = 29.35;
const basicModeRowHeight = 34.4;
const viewMaxTurn = ref(flippedMaxTurn);
const rowGridStyle = ref({
display: "grid",
gridTemplateRows: `repeat(${viewMaxTurn.value}, ${isEditMode.value ? editModeRowHeight : basicModeRowHeight}px)`,
});
watch([isEditMode, viewMaxTurn], ([isEditMode, maxTurn]) => {
rowGridStyle.value.gridTemplateRows = `repeat(${maxTurn}, ${isEditMode ? editModeRowHeight : basicModeRowHeight}px)`;
});
const isDragSingle = ref(false);
const isDragToggle = ref(false);
const invCommandMap: Record<string, CommandItem> = {};
for (const category of commandList) {
for (const command of category.values) {
invCommandMap[command.value] = command;
}
}
function toggleViewMaxTurn() {
if (viewMaxTurn.value == flippedMaxTurn) {
viewMaxTurn.value = maxTurn;
} else {
viewMaxTurn.value = flippedMaxTurn;
}
}
async function repeatGeneralCommand(amount: number) {
try {
await SammoAPI.Command.RepeatCommand({ amount });
} catch (e) {
console.error(e);
alert(`실패했습니다: ${e}`);
return;
}
await reloadCommandList();
}
async function pushGeneralCommand(amount: number) {
try {
await SammoAPI.Command.PushCommand({ amount });
} catch (e) {
console.error(e);
alert(`실패했습니다: ${e}`);
return;
}
await reloadCommandList();
}
const serverNow = ref(new Date());
function pushGeneralCommandSingle(e: Event) {
//NOTE: split 구현에 버그가 있어서, 수동으로 구분해야함
if (isDropdownChildren(e)) {
return;
}
if (!isEditMode.value) {
return;
}
void pushGeneralCommand(1);
}
function pullGeneralCommandSingle(e: Event) {
//NOTE: split 구현에 버그가 있어서, 수동으로 구분해야함
if (isDropdownChildren(e)) {
return;
}
if (!isEditMode.value) {
return;
}
void pushGeneralCommand(-1);
}
async function reloadCommandList() {
let result: ReservedCommandResponse;
try {
result = await SammoAPI.Command.GetReservedCommand();
} catch (e) {
console.error(e);
alert(`실패했습니다: ${e}`);
return;
}
let yearMonth = joinYearMonth(result.year, result.month);
const turnTime = parseTime(result.turnTime);
let nextTurnTime = new Date(turnTime);
const autorunLimitYearMonth = result.autorun_limit ?? yearMonth - 1;
const [autorunLimitYear, autorunLimitMonth] = parseYearMonth(autorunLimitYearMonth);
reservedCommandList.value = [];
for (const obj of result.turn) {
const [year, month] = parseYearMonth(yearMonth);
let tooltip: string[] = [];
let style: Record<string, unknown> = {};
const brief = obj.brief;
if (yearMonth <= autorunLimitYearMonth) {
if (obj.brief == "휴식") {
obj.brief = "휴식<small>(자율 행동)</small>";
}
style.color = "#aaffff";
tooltip.push(`자율 행동 기간: ${autorunLimitYear}${autorunLimitMonth}월까지`);
}
if (mb_strwidth(brief) > 22) {
tooltip.push(brief);
}
reservedCommandList.value.push({
...obj,
year,
month,
time: formatTime(nextTurnTime, "HH:mm"),
tooltip: tooltip.length == 0 ? undefined : tooltip.join("\n"),
style,
});
yearMonth += 1;
nextTurnTime = addMinutes(nextTurnTime, result.turnTerm);
}
serverNow.value = parseTime(result.date);
}
async function reserveCommandDirect(args: [number[], TurnObj][], reload = true): Promise<boolean> {
const query: {
turnList: number[];
action: string;
arg: Args;
}[] = [];
for (const [turnList, { action, arg }] of args) {
query.push({
turnList,
action,
arg,
});
}
try {
await SammoAPI.Command.ReserveBulkCommand(query);
queryActionHelper.releaseSelectedTurnList();
} catch (e) {
console.error(e);
alert(`실패했습니다: ${e}`);
return false;
}
if (reload) {
await reloadCommandList();
}
return true;
}
async function reserveCommand() {
let reqTurnList: number[] = queryActionHelper.getSelectedTurnList();
const commandName = selectedCommand.value.value;
if (listReqArgCommand.has(commandName)) {
document.location.href = stringifyUrl({
url: "v_processing.php",
query: {
command: commandName,
turnList: reqTurnList.join("_"),
},
});
return;
}
try {
const result = await SammoAPI.Command.ReserveCommand({
turnList: reqTurnList,
action: commandName,
});
storedActionsHelper.pushRecentActions({
action: commandName,
brief: result.brief,
arg: {},
});
queryActionHelper.releaseSelectedTurnList();
} catch (e) {
console.error(e);
alert(`실패했습니다: ${e}`);
return;
}
await reloadCommandList();
}
function chooseCommand(val?: string) {
if (!val) {
return;
}
selectedCommand.value = invCommandMap[val];
void reserveCommand();
}
const commandQuickReserveForm = ref<InstanceType<typeof CommandSelectForm> | null>(null);
const currentQuickReserveTarget = ref(-1);
function chooseQuickReserveCommand(val?: string) {
if (!val) {
return;
}
selectedCommand.value = invCommandMap[val];
selectedTurnList.value.clear();
selectedTurnList.value.add(currentQuickReserveTarget.value);
void reserveCommand();
}
function toggleQuickReserveForm(turnIdx: number) {
if (turnIdx == currentQuickReserveTarget.value) {
commandQuickReserveForm.value?.toggle();
return;
}
currentQuickReserveTarget.value = turnIdx;
commandQuickReserveForm.value?.show();
}
watch(isEditMode, (newEditMode) => {
if (newEditMode) {
commandQuickReserveForm.value?.close();
currentQuickReserveTarget.value = -1;
} else {
commandSelectForm.value?.close();
}
});
const emptyTurnObj: TurnObj = { action: "휴식", brief: "휴식", arg: {} };
const recentActions = storedActionsHelper.recentActions;
const storedActions = storedActionsHelper.storedActions;
const activatedCategory = storedActionsHelper.activatedCategory;
async function eraseSelectedTurnList(releaseSelect = true): Promise<boolean> {
const result = await reserveCommandDirect([[queryActionHelper.getSelectedTurnList(), emptyTurnObj]]);
if (releaseSelect) {
queryActionHelper.releaseSelectedTurnList();
}
return result;
}
const clipboard = storedActionsHelper.clipboard;
async function clipboardCut(releaseSelect = true) {
clipboardCopy(false);
return eraseSelectedTurnList(releaseSelect);
}
function clipboardCopy(releaseSelect = true) {
clipboard.value = queryActionHelper.extractQueryActions();
if (releaseSelect) {
queryActionHelper.releaseSelectedTurnList();
}
}
async function clipboardPaste(releaseSelect = true) {
const rawActions = clipboard.value;
if (rawActions === undefined) {
return;
}
const actions = queryActionHelper.amplifyQueryActions(rawActions, queryActionHelper.getSelectedTurnList());
if (actions.length === 0) {
return;
}
const result = await reserveCommandDirect(actions);
if (releaseSelect) {
queryActionHelper.releaseSelectedTurnList();
}
return result;
}
async function subRepeatCommand(releaseSelect = true): Promise<boolean> {
const reqTurnList = queryActionHelper.getSelectedTurnList();
const selectedMinTurnIdx = reqTurnList[0];
const selectedMaxTurnIdx = reqTurnList[reqTurnList.length - 1];
const queryLength = selectedMaxTurnIdx - selectedMinTurnIdx + 1;
const rawActions = queryActionHelper.extractQueryActions();
const actions = queryActionHelper.amplifyQueryActions(rawActions, range(selectedMinTurnIdx, maxTurn, queryLength));
const result = await reserveCommandDirect(actions);
if (releaseSelect) {
queryActionHelper.releaseSelectedTurnList();
}
return result;
}
async function eraseAndPullCommand(releaseSelect = true): Promise<boolean> {
const reqTurnList = queryActionHelper.getSelectedTurnList();
const selectedMinTurnIdx = reqTurnList[0];
const selectedMaxTurnIdx = reqTurnList[reqTurnList.length - 1];
const queryLength = selectedMaxTurnIdx - selectedMinTurnIdx + 1;
if (selectedMinTurnIdx === 0) {
await pushGeneralCommand(-queryLength);
return true;
}
if (selectedMinTurnIdx + queryLength == maxTurn) {
return eraseSelectedTurnList(releaseSelect);
}
const actions: [number[], TurnObj][] = [];
const emptyTurnList: number[] = [];
for (const srcTurnIdx of range(selectedMinTurnIdx + queryLength, maxTurn)) {
const rawAction = reservedCommandList.value[srcTurnIdx];
if (rawAction.action == emptyTurnObj.action) {
emptyTurnList.push(srcTurnIdx - queryLength);
continue;
}
actions.push([
[srcTurnIdx - queryLength],
{
action: rawAction.action,
arg: rawAction.arg,
brief: rawAction.brief,
},
]);
}
emptyTurnList.push(...range(maxTurn - queryLength, maxTurn));
actions.push([emptyTurnList, emptyTurnObj]);
const result = await reserveCommandDirect(actions);
if (releaseSelect) {
queryActionHelper.releaseSelectedTurnList();
}
return result;
}
async function pushEmptyCommand(releaseSelect = true): Promise<boolean> {
const reqTurnList = queryActionHelper.getSelectedTurnList();
const selectedMinTurnIdx = reqTurnList[0];
const selectedMaxTurnIdx = reqTurnList[reqTurnList.length - 1];
const queryLength = selectedMaxTurnIdx - selectedMinTurnIdx + 1;
if (selectedMinTurnIdx === 0) {
await pushGeneralCommand(queryLength);
return true;
}
if (selectedMaxTurnIdx == maxTurn) {
return eraseSelectedTurnList(releaseSelect);
}
const actions: [number[], TurnObj][] = [];
const emptyTurnList: number[] = [];
for (const srcTurnIdx of range(selectedMinTurnIdx, maxTurn - queryLength)) {
const rawAction = reservedCommandList.value[srcTurnIdx];
if (rawAction.action == emptyTurnObj.action) {
emptyTurnList.push(srcTurnIdx + queryLength);
continue;
}
actions.push([
[srcTurnIdx + queryLength],
{
action: rawAction.action,
arg: rawAction.arg,
brief: rawAction.brief,
},
]);
}
emptyTurnList.push(...range(selectedMinTurnIdx, selectedMinTurnIdx + queryLength));
actions.push([emptyTurnList, emptyTurnObj]);
const result = await reserveCommandDirect(actions);
if (releaseSelect) {
queryActionHelper.releaseSelectedTurnList();
}
return result;
}
function setStoredActions() {
const actions = queryActionHelper.extractQueryActions();
const turnBrief = new Map<number, string>();
for (const [subTurnList, action] of actions) {
const actionName = action.action.split("_");
const actionShortName = actionName.length == 1 ? actionName[0] : actionName[1];
for (const turnIdx of subTurnList) {
turnBrief.set(turnIdx, actionShortName[0]);
}
}
const turnBriefStr = Array.from(turnBrief.entries())
.sort(([turnA], [turnB]) => turnA - turnB)
.map(([, action]) => action)
.join("");
const nickName = trim(prompt("선택한 턴들의 별명을 지어주세요", turnBriefStr) ?? "");
if (nickName == "") {
return;
}
storedActionsHelper.setStoredActions(nickName, actions);
queryActionHelper.releaseSelectedTurnList();
}
function deleteStoredActions(actionKey: string) {
storedActionsHelper.deleteStoredActions(actionKey);
}
async function useStoredAction(rawActions: [number[], TurnObj][]) {
const reqTurnList = queryActionHelper.getSelectedTurnList();
const actions = queryActionHelper.amplifyQueryActions(rawActions, reqTurnList);
const result = await reserveCommandDirect(actions);
queryActionHelper.releaseSelectedTurnList();
return result;
}
onMounted(() => {
void reloadCommandList();
});
</script>
<style lang="scss">
@use "sass:color";
@import "@scss/common/break_500px.scss";
@import "@scss/common/variables.scss";
@import "@scss/common/bootswatch_custom_variables.scss";
@import "@scss/game_bg.scss";
.commandPad {
background-color: $gray-900;
position: relative;
}
.commandTable {
width: 100%;
display: grid;
grid-template-columns:
minmax(20px, 0.8fr) minmax(75px, 2.4fr) minmax(40px, 0.9fr)
4.8fr minmax(28px, 0.8fr);
//30, 70, 37.65, 160
}
.commandTable.isEditMode {
width: 100%;
display: grid;
grid-template-columns: minmax(30px, 1fr) minmax(75px, 2.5fr) minmax(40px, 1fr) 5fr;
//30, 70, 37.65, 160
}
@include media-1000px {
.commandPad {
margin-left: 10px;
.turn_pad {
overflow: hidden;
text-overflow: ellipsis;
}
.multiselect__content-wrapper {
margin-left: calc(-100% / 7 * 2);
width: calc(100% / 7 * 12);
}
.multiselect__single {
display: inline-block;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
}
}
@include media-500px {
.dropdown-item {
padding: 8px;
}
.commandPad {
margin-top: 10px;
margin-bottom: 10px;
.btn {
transition: none !important;
}
}
.month_pad,
.time_pad,
.turn_pad {
padding: 6px;
}
}
.isEditMode .month_pad:hover {
text-decoration: underline;
cursor: pointer;
}
.plain-center {
background-color: black;
}
.plain-center,
.month_pad,
.time_pad,
.turn_pad {
display: flex;
justify-content: center;
align-items: center;
}
.turn_pad {
white-space: nowrap;
background-color: $nbase2color;
}
.turn_pad:nth-child(2n) {
background-color: color.adjust($nbase2color, $lightness: -5%);
}
.turn_pad .turn_text {
display: inline-block;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
</style>
+272
View File
@@ -0,0 +1,272 @@
import type { Args } from "./processing/args";
import {
callSammoAPI,
extractHttpMethod,
GET,
PATCH,
POST,
PUT,
type APITail,
type APICallT,
type RawArgType,
type ValidResponse,
type InvalidResponse,
} from "./util/callSammoAPI";
export type { ValidResponse, InvalidResponse };
import { APIPathGen, NumVar, StrVar } from "./util/APIPathGen.js";
import type { BettingDetailResponse, BettingListResponse } from "./defs/API/Betting";
import type { ReserveBulkCommandResponse, ReserveCommandResponse, ReservedCommandResponse } from "./defs/API/Command";
import type { ChiefResponse } from "./defs/API/NationCommand";
import type { inheritBuffType, InheritLogResponse } from "./defs/API/InheritAction";
import type { SetBlockWarResponse, GeneralListResponse as NationGeneralListResponse } from "./defs/API/Nation";
import type { UploadImageResponse } from "./defs/API/Misc";
import type { GeneralLogType, GetGeneralLogResponse, JoinArgs } from "./defs/API/General";
import type {
GetConstResponse,
GetCurrentHistoryResponse,
GetDiplomacyResponse,
GetHistoryResponse,
} from "./defs/API/Global";
import type { CachedMapResult, GeneralListResponse, ItemTypeKey, MapResult } from "./defs";
import type { VoteDetailResult, VoteListResult } from "./defs/API/Vote";
import type { ActiveResourceAuctionList, OpenAuctionResponse, UniqueItemAuctionDetail, UniqueItemAuctionList } from "./defs/API/Auction";
const apiRealPath = {
Auction: {
BidBuyRiceAuction: PUT as APICallT<{
auctionID: number;
amount: number;
}>,
BidSellRiceAuction: PUT as APICallT<{
auctionID: number;
amount: number;
}>,
GetActiveResourceAuctionList: GET as APICallT<undefined, ActiveResourceAuctionList>,
OpenBuyRiceAuction: POST as APICallT<
{
amount: number;
closeTurnCnt: number;
startBidAmount: number;
finishBidAmount: number;
},
OpenAuctionResponse
>,
OpenSellRiceAuction: POST as APICallT<
{
amount: number;
closeTurnCnt: number;
startBidAmount: number;
finishBidAmount: number;
},
OpenAuctionResponse
>,
BidUniqueAuction: PUT as APICallT<{
auctionID: number;
amount: number;
}>,
GetUniqueItemAuctionDetail: GET as APICallT<{
auctionID: number;
}, UniqueItemAuctionDetail>,
GetUniqueItemAuctionList: GET as APICallT<undefined, UniqueItemAuctionList>,
OpenUniqueAuction: POST as APICallT<{
itemID: string,
amount: number,
}, OpenAuctionResponse>,
},
Betting: {
Bet: PUT as APICallT<{
bettingID: number;
bettingType: number[];
amount: number;
}>,
GetBettingDetail: NumVar("betting_id", GET as APICallT<undefined, BettingDetailResponse>),
GetBettingList: GET as APICallT<
{
req?: "bettingNation" | "tournament";
},
BettingListResponse
>,
},
Command: {
GetReservedCommand: GET as APICallT<undefined, ReservedCommandResponse>,
PushCommand: PUT as APICallT<{
amount: number;
}>,
RepeatCommand: PUT as APICallT<{
amount: number;
}>,
ReserveCommand: PUT as APICallT<
{
turnList: number[];
action: string;
arg?: Args;
},
ReserveCommandResponse
>,
ReserveBulkCommand: PUT as APICallT<
{
turnList: number[];
action: string;
arg?: Args;
}[],
ReserveBulkCommandResponse
>,
},
General: {
Join: POST as APICallT<JoinArgs>,
GetGeneralLog: GET as APICallT<
{
reqType: GeneralLogType;
reqTo?: number;
},
GetGeneralLogResponse
>,
DropItem: PUT as APICallT<{
itemType: ItemTypeKey;
}>,
DieOnPrestart: POST as APICallT<undefined>,
BuildNationCandidate: POST as APICallT<undefined>,
},
Global: {
GeneralList: GET as APICallT<undefined, GeneralListResponse>,
GeneralListWithToken: GET as APICallT<undefined, GeneralListResponse>,
GetConst: GET as APICallT<undefined, GetConstResponse>,
GetHistory: StrVar("serverID")(NumVar("year", NumVar("month", GET as APICallT<undefined, GetHistoryResponse>))),
GetCurrentHistory: GET as APICallT<undefined, GetCurrentHistoryResponse>,
GetMap: GET as APICallT<
{
neutralView?: 0 | 1;
showMe?: 0 | 1;
},
MapResult
>,
GetCachedMap: GET as APICallT<undefined, CachedMapResult>,
GetDiplomacy: GET as APICallT<undefined, GetDiplomacyResponse>,
ExecuteEngine: POST as APICallT<undefined, ValidResponse & { updated: boolean }>,
},
InheritAction: {
BuyHiddenBuff: PUT as APICallT<{
type: inheritBuffType;
level: number;
}>,
BuyRandomUnique: PUT as APICallT<undefined>,
ResetSpecialWar: PUT as APICallT<undefined>,
ResetTurnTime: PUT as APICallT<undefined>,
SetNextSpecialWar: PUT as APICallT<{
type: string;
}>,
GetMoreLog: GET as APICallT<{
lastID: number
}, InheritLogResponse>
},
Misc: {
UploadImage: POST as APICallT<
{
imageData: string;
},
UploadImageResponse
>,
},
NationCommand: {
GetReservedCommand: GET as APICallT<undefined, ChiefResponse>,
PushCommand: PUT as APICallT<{
amount: number;
}>,
RepeatCommand: PUT as APICallT<{
amount: number;
}>,
ReserveCommand: PUT as APICallT<
{
turnList: number[];
action: string;
arg?: Args;
},
ReserveCommandResponse
>,
ReserveBulkCommand: PUT as APICallT<
{
turnList: number[];
action: string;
arg?: Args;
}[],
ReserveBulkCommandResponse
>,
},
Nation: {
GeneralList: GET as APICallT<undefined, NationGeneralListResponse>,
SetNotice: PUT as APICallT<{
msg: string;
}>,
SetScoutMsg: PUT as APICallT<{
msg: string;
}>,
SetBill: PATCH as APICallT<{
amount: number;
}>,
SetRate: PATCH as APICallT<{
amount: number;
}>,
SetSecretLimit: PATCH as APICallT<{
amount: number;
}>,
SetBlockWar: PATCH as APICallT<
{
value: boolean;
},
SetBlockWarResponse
>,
SetBlockScout: PATCH as APICallT<{
value: boolean;
}>,
GetGeneralLog: GET as APICallT<
{
generalID: number;
reqType: GeneralLogType;
reqTo?: number;
},
GetGeneralLogResponse
>,
SetTroopName: PATCH as APICallT<{
troopID: number;
troopName: string;
}>,
},
Vote: {
AddComment: POST as APICallT<{
voteID: number;
text: string;
}>,
GetVoteList: GET as APICallT<undefined, VoteListResult>,
GetVoteDetail: GET as APICallT<
{
voteID: number;
},
VoteDetailResult
>,
NewVote: POST as APICallT<{
title: string;
multipleOptions?: number;
endDate?: string;
options: string[];
keepOldVote?: boolean;
}>,
Vote: POST as APICallT<
{
voteID: number;
selection: number[];
},
ValidResponse & { wonLottery: boolean }
>,
},
} as const;
export const SammoAPI = APIPathGen(apiRealPath, (path: string[], tail: APITail, pathParam) => {
const method = extractHttpMethod(tail);
return (args?: RawArgType, returnError?: boolean) => {
if (returnError) {
return callSammoAPI(method, path.join("/"), args, pathParam, true);
}
return callSammoAPI(method, path.join("/"), args, pathParam);
};
});
+28
View File
@@ -0,0 +1,28 @@
import type { AutoLoginFailed, AutoLoginNonceResponse, AutoLoginResponse, LoginFailed, LoginResponse } from "./defs/API/Login";
import { APIPathGen } from "./util/APIPathGen";
import { callSammoAPI, extractHttpMethod, GET, POST, type APICallT, type APITail, type InvalidResponse, type RawArgType, type ValidResponse } from "./util/callSammoAPI";
export type { ValidResponse, InvalidResponse };
const apiRealPath = {
Login: {
LoginByID: POST as APICallT<{
username: string,
password: string,
}, LoginResponse, LoginFailed>,
LoginByToken: POST as APICallT<{
hashedToken: string,
token_id: number,
}, AutoLoginResponse, AutoLoginFailed>,
ReqNonce: GET as APICallT<undefined, AutoLoginNonceResponse, AutoLoginFailed>
},
} as const;
export const SammoRootAPI = APIPathGen(apiRealPath, (path: string[], tail: APITail, pathParam) => {
const method = extractHttpMethod(tail);
return (args?: RawArgType, returnError?: boolean) => {
if (returnError) {
return callSammoAPI(method, path.join('/'), args, pathParam, true);
}
return callSammoAPI(method, path.join('/'), args, pathParam);
};
});
+10
View File
@@ -0,0 +1,10 @@
import "@scss/battleCenter.scss";
import { auto500px } from "./util/auto500px";
import { insertCustomCSS } from "./util/customCSS";
import { htmlReady } from "./util/htmlReady";
auto500px();
htmlReady(() => {
insertCustomCSS();
});
+1067
View File
@@ -0,0 +1,1067 @@
import $ from 'jquery';
import 'bootstrap';
import download from 'downloadjs';
import { unwrap } from "@util/unwrap";
import { isInteger } from 'lodash';
import { errUnknown, getNpcColor } from '@/common_legacy';
import { combineArray } from "@util/combineArray";
import { isBrightColor } from "@util/isBrightColor";
import { numberWithCommas } from "@util/numberWithCommas";
import { unwrap_any } from '@util/unwrap_any';
import type { BasicGeneralListResponse, InvalidResponse } from '@/defs';
import { formatTime } from '@util/formatTime';
import { Modal } from 'bootstrap';
type CityAttackerInfo = {
level: number,
}
type CityBasicInfo = {
nation?: number,
level: number,
def: number,
wall: number,
city?: number,
}
type NationBasicInfo = {
level: number,
type: string,
tech: number,
capital: number,
}
type GeneralInfo = {
no: number,
npc?: number
name: string,
officer_level: number,
explevel: number,
leadership: number,
horse: string,
strength: number,
weapon: string,
intel: number,
book: string,
item: string,
injury: number,
rice: number,
personal: string,
special2: string,
crew: number,
crewtype: number,
atmos: number,
train: number,
dex1: number,
dex2: number,
dex3: number,
dex4: number,
dex5: number,
defence_train: number,
warnum: number,
killnum: number,
killcrew: number,
officer_city?: number,
};
type BattleInfo = {
attackerGeneral: GeneralInfo,
attackerCity: CityAttackerInfo | CityBasicInfo,
attackerNation: NationBasicInfo,
defenderGenerals: GeneralInfo[],
defenderCity: CityBasicInfo,
defenderNation: NationBasicInfo,
year: number,
month: number,
repeatCnt: number,
};
type ExportedGeneralInfo = {
objType: 'general',
data: GeneralInfo,
}
type ExportedBattleInfo = {
objType: 'battle',
data: BattleInfo
}
type ExportedInfo = ExportedGeneralInfo | ExportedBattleInfo;
type BattleResult = {
result: true,
datetime: string,
lastWarLog: {
generalBattleResultLog: string,
generalBattleDetailLog: string,
},
avgWar: number,
phase: number,
killed: number,
maxKilled: number,
minKilled: number,
dead: number,
maxDead: number,
minDead: number,
attackerRice: number,
defenderRice: number,
attackerSkills: Record<string, number>,
defendersSkills: Record<string, number>[],
}
declare global {
interface Window {
nation?: NationBasicInfo,
city?: CityBasicInfo,
defaultSpecialDomestic: string,
}
}
let modalImport: Modal|undefined = undefined;
$(function ($) {
const $generalForm = $('.form_sample .general_detail');
const $defenderHeaderForm = $('.form_sample .card-header');
const $defenderColumn = $('.defender-column');
const defenderNoList: Record<number, JQuery<HTMLElement>> = {};
const $attackerCard = $('.attacker_form');
const initBasicEvent = function () {
if (window.nation && window.city) {
$('.form_city_level').val(window.city.level);
$('.form_def').val(window.city.def);
$('.form_wall').val(window.city.wall);
$('.form_nation_type').val(window.nation.type);
$('.form_nation_level').val(window.nation.level);
$('.form_tech').val(window.nation.tech / 1000);
if (window.nation.capital == window.city.city) {
$('.attacker_nation .form_is_capital:first').trigger('click');
$('.defender_nation .form_is_capital:first').trigger('click');
} else {
$('.attacker_nation .form_is_capital:last').trigger('click');
$('.defender_nation .form_is_capital:last').trigger('click');
}
} else {
$('.attacker_nation .form_is_capital:last').trigger('click');
$('.defender_nation .form_is_capital:last').trigger('click');
}
$('.form_injury').on('change', function () {
const $this = $(this) as JQuery<HTMLInputElement>;
const $general = getGeneralDetail($this);
const $helptext = $general.find('.injury_helptext');
const injury = parseInt($this.val() as string);
//FIXME: PHP 코드와 항상 일치하도록 변경
let text = '건강';
let color = 'white';
if (injury > 60) {
text = '위독';
color = 'red';
} else if (injury > 40) {
text = '심각';
color = 'magenta';
} else if (injury > 20) {
text = '중상';
color = 'orange';
} else if (injury > 0) {
text = '경상';
color = 'yellow';
}
$helptext.html(text).css('color', color);
});
$('.export_general').on('click', function () {
const $btn = $(this);
const $general = getGeneralDetail($btn);
const values = exportGeneralInfo($general);
console.log(values);
});
$('.delete-defender').on('click', function () {
const $card = getGeneralFrame($(this));
deleteDefender($card);
});
$('.copy-defender').on('click', function () {
const $card = getGeneralFrame($(this));
copyDefender($card);
});
$('.add-defender').on('click', function () {
addDefender();
});
$('.btn-general-load').on('click', function () {
const $file = $(this).prev();
$file[0].click();
})
$<HTMLInputElement>('.form_load_general_file').on('change', function (e) {
e.preventDefault();
const $this = $(this);
const $card = getGeneralFrame($this);
const files = unwrap(e.target.files);
importGeneralInfoByFile(files, $card);
return false;
});
$('.btn-general-import-server').on('click', function () {
const $this = $(this);
const $card = getGeneralFrame($this);
const $modal = $('#importModal');
$modal.data('target', $card);
if(!modalImport){
modalImport = new Modal($modal[0]);
}
modalImport.show();
});
$('.btn-general-save').on('click', function () {
const $this = $(this);
const $general = getGeneralDetail($this);
const generalData = exportGeneralInfo($general);
const filename = `general_${generalData.name}.json`;
const saveData = JSON.stringify({
objType: 'general',
data: generalData
}, null, 4);
download(saveData, filename, 'application/json');
});
$('.btn-battle-load').on('click', function () {
const $file = $(this).prev();
$file[0].click();
})
$<HTMLInputElement>('.form_load_battle_file').on('change', function (e) {
e.preventDefault();
const files = unwrap(e.target.files);
importBattleInfoByFile(files);
return false;
});
$('.btn-battle-save').on('click', function () {
const battleData = exportAllData();
const dateText = formatTime(new Date(), 'yyyyMMdd_HHmmss');
const filename = `battle_${dateText}.json`;
const saveData = JSON.stringify({
objType: 'battle',
data: battleData
}, null, 4);
download(saveData, filename, 'application/json');
})
const $generals = $('.general_detail');
$generals.on('dragover dragleave', function (e) {
e.stopPropagation()
e.preventDefault()
})
$generals.on('drop', function (e) {
e.preventDefault();
const $this = $(this);
const $card = getGeneralFrame($this);
const files = unwrap((unwrap(e.originalEvent) as DragEvent).dataTransfer).files;
importGeneralInfoByFile(files, $card);
return false;
});
const $battlePad = $('.dragpad_battle');
$battlePad.on('dragover dragleave', function (e) {
e.stopPropagation()
e.preventDefault()
})
$battlePad.on('drop', function (e) {
e.preventDefault();
const files = unwrap((unwrap(e.originalEvent) as DragEvent).dataTransfer).files;
importBattleInfoByFile(files);
return false;
});
}
const importGeneralInfo = function ($general: JQuery<HTMLElement>, data: GeneralInfo) {
const setVal = function (query: string, val: string | number) {
$general.find(query).val(val).trigger('change');
}
setVal('.form_general_name', data.name);
setVal('.form_officer_level', data.officer_level);
setVal('.form_exp_level', data.explevel);
setVal('.form_leadership', data.leadership);
setVal('.form_general_horse', data.horse);
setVal('.form_strength', data.strength);
setVal('.form_general_weap', data.weapon);
setVal('.form_intel', data.intel);
setVal('.form_general_book', data.book);
setVal('.form_general_item', data.item);
setVal('.form_injury', data.injury);
setVal('.form_rice', data.rice);
setVal('.form_general_character', data.personal);
setVal('.form_general_special_war', data.special2);
setVal('.form_crew', data.crew);
setVal('.form_crewtype', data.crewtype);
setVal('.form_atmos', data.atmos);
setVal('.form_train', data.train);
setVal('.form_dex1', data.dex1);
setVal('.form_dex2', data.dex2);
setVal('.form_dex3', data.dex3);
setVal('.form_dex4', data.dex4);
setVal('.form_dex5', data.dex5);
setVal('.form_defence_train', data.defence_train);
setVal('.form_warnum', data.warnum);
setVal('.form_killnum', data.killnum);
setVal('.form_killcrew', data.killcrew);
if (!setGeneralNo($general, data.no)) {
setGeneralNo($general, generateNewGeneralNo());
}
}
const exportGeneralInfo = function ($general: JQuery<HTMLElement>): GeneralInfo {
const getInt = function (query: string): number {
return parseInt(unwrap_any<string>($general.find(query).val()));
}
const getVal = function (query: string): string {
return unwrap_any<string>($general.find(query).val());
}
return {
no: getGeneralNo($general),
name: getVal('.form_general_name'),
officer_level: getInt('.form_officer_level'),
explevel: getInt('.form_exp_level'),
leadership: getInt('.form_leadership'),
horse: getVal('.form_general_horse'),
strength: getInt('.form_strength'),
weapon: getVal('.form_general_weap'),
intel: getInt('.form_intel'),
book: getVal('.form_general_book'),
item: getVal('.form_general_item'),
injury: getInt('.form_injury'),
rice: getInt('.form_rice'),
personal: getVal('.form_general_character'),
special2: getVal('.form_general_special_war'),
crew: getInt('.form_crew'),
crewtype: getInt('.form_crewtype'),
atmos: getInt('.form_atmos'),
train: getInt('.form_train'),
dex1: getInt('.form_dex1'),
dex2: getInt('.form_dex2'),
dex3: getInt('.form_dex3'),
dex4: getInt('.form_dex4'),
dex5: getInt('.form_dex5'),
defence_train: getInt('.form_defence_train'),
warnum: getInt('.form_warnum'),
killnum: getInt('.form_killnum'),
killcrew: getInt('.form_killcrew'),
};
}
const importBattleInfoByFile = function (files: FileList) {
if (files === null) {
alert('파일 에러!');
return false;
}
if (files.length < 1) {
alert("파일 에러!");
return false;
}
const file = files[0];
if (file.size > 1024 * 1024) {
alert('파일이 너무 큽니다!');
return false;
}
if (file.type === '') {
alert('폴더를 업로드할 수 없습니다!');
return false;
}
const reader = new FileReader();
reader.onload = function () {
let battleData: ExportedInfo;
try {
battleData = JSON.parse(unwrap_any<string>(reader.result));
} catch (e) {
alert('올바르지 않은 파일 형식입니다');
return false;
}
if (!('objType' in battleData)) {
alert('파일 형식을 확인할 수 없습니다');
return false;
}
if (battleData.objType != 'battle') {
alert('전투 데이터가 아닙니다');
return false;
}
importBattleInfo(battleData.data);
return true;
};
try {
reader.readAsText(file);
} catch (e) {
alert('파일을 읽는데 실패했습니다.');
return false;
}
return true;
}
const importGeneralInfoByFile = function (files: FileList, $card: JQuery<HTMLElement>) {
if ($card === undefined) {
$card = addDefender();
}
if (files === null) {
alert('파일 에러!');
return false;
}
if (files.length < 1) {
alert("파일 에러!");
return false;
}
const file = files[0];
if (file.size > 1024 * 1024) {
alert('파일이 너무 큽니다!');
return false;
}
if (file.type === '') {
alert('폴더를 업로드할 수 없습니다!');
return false;
}
const reader = new FileReader();
reader.onload = function () {
let generalData: ExportedInfo;
try {
generalData = JSON.parse(unwrap_any<string>(reader.result));
} catch (e) {
alert('올바르지 않은 파일 형식입니다');
return false;
}
if (!('objType' in generalData)) {
alert('파일 형식을 확인할 수 없습니다');
return false;
}
if (generalData.objType == 'battle') {
importBattleInfo(generalData.data);
return true;
}
if (generalData.objType != 'general') {
alert('장수 데이터가 아닙니다');
return false;
}
$card.find('.form_load_general_file').val('');
importGeneralInfo($card, generalData.data);
return true;
};
try {
reader.readAsText(file);
} catch (e) {
alert('파일을 읽는데 실패했습니다.');
return false;
}
return true;
}
const extendGeneralInfoForDB = function (generalData: GeneralInfo) {
const dbVal = {
nation: (generalData.no) <= 1 ? 1 : 2,
city: (generalData.no) <= 1 ? 1 : 3,
turntime: '2018-08-26 12:00',
special: window.defaultSpecialDomestic,
leadership_exp: 0,
strength_exp: 0,
intel_exp: 0,
gold: 10000,
dedication: 0,
recent_war: '2018-08-26 12:00',
experience: Math.pow(generalData.explevel, 2),
};
return $.extend({}, generalData, dbVal);
}
const getGeneralFrame = function ($btn: JQuery<HTMLElement>) {
const $card = $btn.closest('.general_form');
return $card;
}
const getGeneralDetail = function ($btn: JQuery<HTMLElement>) {
const $card = getGeneralFrame($btn);
const $general = $card.find('.general_detail');
return $general;
}
const getGeneralNo = function ($btn: JQuery<HTMLElement>) {
return parseInt(getGeneralFrame($btn).data('general_no'));
}
const setGeneralNo = function ($btn: JQuery<HTMLElement>, value: number) {
if (value == 1) {
//1번은 무조건 공격자임
return false;
}
if (value in defenderNoList) {
return false;
}
const $card = getGeneralFrame($btn);
$card.data('general_no', value);
defenderNoList[value] = $card;
return true;
}
const generateNewGeneralNo = function () {
for (; ;) {
const newGeneralNo = Math.floor(Math.random() * (1 << 24)) + 2;
if (newGeneralNo in defenderNoList) {
continue;
}
return newGeneralNo;
}
}
const deleteGeneralNo = function ($btn: JQuery<HTMLElement>) {
const $card = getGeneralFrame($btn);
$card.removeData('general_no');
const generalNo = getGeneralNo($card);
delete defenderNoList[generalNo];
}
const addDefender = function ($cardAfter?: JQuery<HTMLElement>) {
const $newCard = $('<div class="card mb-2 defender_form general_form"></div>');
if ($cardAfter === undefined) {
$defenderColumn.append($newCard);
} else {
$cardAfter.after($newCard);
}
$newCard.append($defenderHeaderForm.clone(true, true));
$newCard.append($generalForm.clone(true, true));
//$newGeneral = getGeneralDetail($newCard);
setGeneralNo($newCard, generateNewGeneralNo());
return $newCard;
}
const deleteDefender = function ($card: JQuery<HTMLElement>) {
deleteGeneralNo($card);
$card.detach();
}
const copyDefender = function ($card: JQuery<HTMLElement>) {
const $general = getGeneralDetail($card);
const generalData = exportGeneralInfo($general);
const $newObj = addDefender($card);
importGeneralInfo(getGeneralDetail($newObj), generalData);
}
const importBattleInfo = function (battleData: BattleInfo) {
$('.form_load_battle_file').val('');
console.log(battleData);
const $attackerNation = $('.attacker_nation');
const $defenderNation = $('.defender_nation');
const attackerGeneral = battleData.attackerGeneral;
const attackerCity = battleData.attackerCity;
const attackerNation = battleData.attackerNation;
const defenderGenerals = battleData.defenderGenerals;
const defenderCity = battleData.defenderCity;
const defenderNation = battleData.defenderNation;
$('#year').val(battleData.year);
$('#month').val(battleData.month);
$('#repeat_cnt').val(battleData.repeatCnt);
$('.delete-defender').trigger('click');
$attackerNation.find('.form_nation_type').val(attackerNation.type);
$attackerNation.find('.form_tech').val(Math.floor(attackerNation.tech / 1000));
$attackerNation.find('.form_nation_level').val(attackerNation.level);
if (attackerNation.capital == 1) {
$attackerNation.find('.form_is_capital:first').trigger('click');
} else {
$attackerNation.find('.form_is_capital:last').trigger('click');
}
$attackerNation.find('.form_city_level').val(attackerCity.level);
importGeneralInfo($('.attacker_form'), attackerGeneral);
$defenderNation.find('.form_nation_type').val(defenderNation.type);
$defenderNation.find('.form_tech').val(Math.floor(defenderNation.tech / 1000));
$defenderNation.find('.form_nation_level').val(defenderNation.level);
if (defenderNation.capital == 1) {
$defenderNation.find('.form_is_capital:first').trigger('click');
} else {
$defenderNation.find('.form_is_capital:last').trigger('click');
}
$defenderNation.find('.form_city_level').val(defenderCity.level);
$('#city_def').val(defenderCity.def);
$('#city_wall').val(defenderCity.wall);
$.each(defenderGenerals, function (idx, defenderGeneral) {
const $card = addDefender();
importGeneralInfo($card, defenderGeneral);
});
}
const exportAllData = function (): BattleInfo {
const $attackerNation = $('.attacker_nation');
const $defenderNation = $('.defender_nation');
const attackerGeneral = exportGeneralInfo($('.attacker_form'));
const attackerCity = {
level: parseInt($attackerNation.find('.form_city_level').val() as string),
};
const attackerNation = {
type: unwrap_any<string>($attackerNation.find('.form_nation_type').val()),
tech: parseInt($attackerNation.find('.form_tech').val() as string) * 1000,
level: parseInt($attackerNation.find('.form_nation_level').val() as string),
capital: $attackerNation.find('.form_is_capital:checked').val() == '1' ? 1 : 2,
}
const defenderGenerals = $('.defender_form').map(function () {
return exportGeneralInfo($(this));
}).toArray();
const defenderCity = {
def: parseInt($('#city_def').val() as string),
wall: parseInt($('#city_wall').val() as string),
level: parseInt($defenderNation.find('.form_city_level').val() as string),
};
const defenderNation = {
type: unwrap_any<string>($defenderNation.find('.form_nation_type').val()),
tech: parseInt(unwrap_any<string>($defenderNation.find('.form_tech').val())) * 1000,
level: parseInt(unwrap_any<string>($defenderNation.find('.form_nation_level').val())),
capital: $defenderNation.find('.form_is_capital:checked').val() == '1' ? 3 : 4,
}
const year = parseInt(unwrap_any<string>($('#year').val()));
const month = parseInt(unwrap_any<string>($('#month').val()));
const repeatCnt = parseInt(unwrap_any<string>($('#repeat_cnt').val()));
return {
attackerGeneral: attackerGeneral,
attackerCity: attackerCity,
attackerNation: attackerNation,
defenderGenerals: defenderGenerals,
defenderCity: defenderCity,
defenderNation: defenderNation,
year: year,
month: month,
repeatCnt: repeatCnt,
};
}
const extendAllDataForDB = function (allData: BattleInfo): BattleInfo {
const defaultNation = {
nation: 0,
name: '재야',
capital: 0,
level: 0,
gold: 0,
rice: 2000,
type: 'None',
tech: 0,
gennum: 200,
};
const defaultCity = {
nation: 0,
supply: 1,
name: '도시',
pop: 500000,
agri: 10000,
comm: 10000,
secu: 10000,
def: 1000,
wall: 1000,
trust: 100,
pop_max: 600000,
agri_max: 12000,
comm_max: 12000,
secu_max: 10000,
def_max: 12000,
wall_max: 12000,
dead: 0,
state: 0,
conflict: '{}',
};
const attackerNation = $.extend({}, defaultNation, allData.attackerNation);
attackerNation.nation = 1;
attackerNation.name = '출병국';
const attackerCity = $.extend({}, defaultCity, allData.attackerCity) as CityBasicInfo;
attackerCity.nation = 1;
attackerCity.city = 1;
const attackerGeneral = extendGeneralInfoForDB(allData.attackerGeneral);
if (2 <= attackerGeneral.officer_level && attackerGeneral.officer_level <= 4) {
attackerGeneral.officer_city = 1;
} else {
attackerGeneral.officer_city = 0;
}
const defenderNation = $.extend({}, defaultNation, allData.defenderNation);
defenderNation.nation = 2;
defenderNation.name = '수비국';
const defenderCity = $.extend({}, defaultCity, allData.defenderCity);
defenderCity.nation = 2;
defenderCity.city = 3;
defenderCity.wall_max = Math.floor(defenderCity.wall / 5 * 6);
defenderCity.def_max = Math.floor(defenderCity.def / 5 * 6);
const defenderGenerals: GeneralInfo[] = [];
$.each(allData.defenderGenerals, function () {
const defenderGeneral = extendGeneralInfoForDB(this);
if (2 <= defenderGeneral.officer_level && defenderGeneral.officer_level <= 4) {
defenderGeneral.officer_city = 3;
} else {
defenderGeneral.officer_city = 0;
}
defenderGenerals.push(defenderGeneral);
});
return $.extend({}, allData, {
attackerGeneral: attackerGeneral,
attackerCity: attackerCity,
attackerNation: attackerNation,
defenderGenerals: defenderGenerals,
defenderCity: defenderCity,
defenderNation: defenderNation,
});
}
const parseSkillCount = function (skills: Record<string, number>) {
const result: string[] = [];
for (const [key, value] of Object.entries(skills)) {
result.push(`${key}(${toPretty(value)}회)`);
}
if (result.length == 0) {
return '-';
}
return result.join(', ');
}
const toPretty = function (number: number) {
if (isInteger(number)) {
number = Math.floor(number);
} else {
number = parseFloat(number.toFixed(2));
}
return numberWithCommas(number);
}
const showBattleResult = function (result: BattleResult) {
$('#result_datetime').html(result.datetime);
$('#result_warcnt').html(toPretty(result.avgWar));
$('#result_phase').html(toPretty(result.phase));
$('#result_killed').html(toPretty(result.killed));
if (result.minKilled != result.maxKilled) {
$('#result_maxKilled').html(toPretty(result.maxKilled));
$('#result_minKilled').html(toPretty(result.minKilled));
$('#result_varKilled').show();
} else {
$('#result_varKilled').hide();
}
$('#result_dead').html(toPretty(result.dead));
if (result.minDead != result.maxDead) {
$('#result_maxDead').html(toPretty(result.maxDead));
$('#result_minDead').html(toPretty(result.minDead));
$('#result_varDead').show();
} else {
$('#result_varDead').hide();
}
$('#result_attackerRice').html(toPretty(result.attackerRice));
$('#result_defenderRice').html(toPretty(result.defenderRice));
$('#result_attackerSkills').html(parseSkillCount(result.attackerSkills));
$('.result_defenderSkills').detach();
const $summary = $('#battle_result_summary');
for (const [idx, defenderSkills] of Object.entries(result.defendersSkills)) {
console.log(defenderSkills);
const $result = $(`<tr class='result_defenderSkills'><th>수비자${parseInt(idx) + 1} 스킬</th><td></td></tr>`);
$result.find('td').html(parseSkillCount(defenderSkills));
$summary.append($result);
}
$('#generalBattleResultLog').html(result.lastWarLog.generalBattleResultLog);
$('#generalBattleDetailLog').html(result.lastWarLog.generalBattleDetailLog);
}
const beginBattle = function () {
const data = extendAllDataForDB(exportAllData());
console.log(data);
$.ajax({
type: 'post',
url: 'j_simulate_battle.php',
dataType: 'json',
data: {
action: 'battle',
query: JSON.stringify(data),
}
}).then(function (result) {
console.log(result);
if (!result.result) {
alert(result.reason);
return;
}
showBattleResult(result);
}, function () {
alert('전투 개시 실패!');
});
}
const reorderDefender = function (defenderOrder: number[]) {
for (const generalNo of defenderOrder) {
if (!(generalNo in defenderNoList)) {
//음..?
alert(`${generalNo}이 수비자 리스트에 없습니다. 버그인 듯 합니다.`);
return true;
}
const $defenderObj = defenderNoList[generalNo];
$defenderObj.detach();
$defenderColumn.append($defenderObj);
}
}
const requestReorderDefender = function () {
const data = extendAllDataForDB(exportAllData());
console.log(data);
$.ajax({
type: 'post',
url: 'j_simulate_battle.php',
dataType: 'json',
data: {
action: 'reorder',
query: JSON.stringify(data),
}
}).then(function (result) {
console.log(result);
if (!result.result) {
alert(result.reason);
return;
}
reorderDefender(result.order);
}, function () {
alert('재정렬 실패!');
});
}
let initGeneralList = false;
$('#importFromDB').on('click', function () {
const generalID = $('#modalSelector').val();
console.log(generalID);
$.post({
url: 'j_export_simulator_object.php',
dataType: 'json',
data: {
destGeneralID: generalID
}
}).then(function (data) {
if (!data.result) {
alert(data.reason);
return false;
}
const $modal = $('#importModal');
const $card = $modal.data('target');
importGeneralInfo($card, data.general);
if(modalImport){
modalImport.hide();
}
}, errUnknown);
});
unwrap(document.querySelector('#importModal')).addEventListener('show.bs.modal', function () {
if (!initGeneralList) {
const $list = $('#modalSelector');
const addNation = function (generalList: GeneralInfo[], nationName: string, color: string) {
generalList.sort(function (lhs, rhs) {
if (lhs.npc != rhs.npc) {
return (lhs.npc ?? 0) - (rhs.npc ?? 0);
}
if (lhs.name < rhs.name) {
return -1;
}
if (lhs.name > rhs.name) {
return 1;
}
return 0;
})
const $optGroup = $(`<optgroup label="${nationName}"></optgroup>`);
$optGroup.css('background-color', color);
$optGroup.css('color', isBrightColor(color) ? 'black' : 'white');
for (const general of generalList) {
const $item = $(`<option value="${general.no}">${general.name}</option>`);
if (general.npc) {
$item.css('color', unwrap(getNpcColor(general.npc)));
} else {
$item.css('color', 'white');
}
$item.css('background-color', 'black');
$optGroup.append($item);
}
$list.append($optGroup);
}
$.post({
url: 'j_get_basic_general_list.php',
dataType: 'json',
data: {
req: 2,
}
}).then(function (data: BasicGeneralListResponse | InvalidResponse) {
if (!data.result) {
alert(data.reason);
if(modalImport){
modalImport.hide();
}
return false;
}
const nations = data.nation;
const myNationID = data.nationID;
$list.empty();
//자국 먼저
if (0 in data.list) {
nations[0] = {
nation: 0,
name: '재야',
color: '#000000'
};
}
addNation(combineArray(data.list[myNationID], data.column) as GeneralInfo[], nations[myNationID].name, nations[myNationID].color);
for (const nationID of Object.keys(data.list)) {
if (parseInt(nationID) == myNationID) {
continue;
}
addNation(combineArray(data.list[nationID], data.column) as GeneralInfo[], nations[nationID].name, nations[nationID].color);
}
initGeneralList = true;
}, errUnknown);
}
});
initBasicEvent();
$attackerCard.append($generalForm.clone(true, true));
addDefender();
$('.btn-begin_battle').on('click', function () {
beginBattle();
});
$('.btn-reorder_defender').on('click', function () {
requestReorderDefender();
})
});
+10
View File
@@ -0,0 +1,10 @@
import "@scss/hallOfFame.scss";
import { auto500px } from "./util/auto500px";
import { insertCustomCSS } from "./util/customCSS";
import { htmlReady } from "./util/htmlReady";
auto500px();
htmlReady(() => {
insertCustomCSS();
});
+33
View File
@@ -0,0 +1,33 @@
import $ from 'jquery';
import { unwrap_any } from '@util/unwrap_any';
import { SammoAPI } from './SammoAPI';
declare const staticValues: {
bettingID: number;
}
$(function ($) {
$('.submitBtn').on('click', async function (e) {
e.preventDefault();
const $this = $(this);
const target = parseInt($this.data('target'));
const amount = parseInt(unwrap_any<string>($(`#target_${target}`).val()));
try {
await SammoAPI.Betting.Bet({
bettingID: staticValues.bettingID,
bettingType: [target],
amount: amount,
});
} catch (e) {
console.error(e);
alert(`베팅을 실패했습니다: ${e}`);
location.reload();
return;
}
location.reload();
return;
});
});
+247
View File
@@ -0,0 +1,247 @@
import $ from 'jquery';
import axios from 'axios';
import { convertFormData } from '@util/convertFormData';
import type { InvalidResponse } from '@/defs';
import { unwrap_any } from '@util/unwrap_any';
import { setAxiosXMLHttpRequest } from '@util/setAxiosXMLHttpRequest';
import * as JosaUtil from '@/util/JosaUtil';
import 'bootstrap';
import 'select2/dist/js/select2.full.js'
type GeneralSelectorItem = {
id: string|number,
text: string,
selected: boolean,
}
declare const candidateAmbassadors: GeneralSelectorItem[];
declare const candidateAuditors: GeneralSelectorItem[];
async function changePermission(isAmbassador: boolean, rawGeneralList: GeneralSelectorItem[]) {
console.log(isAmbassador);
console.log(rawGeneralList);
const generalList: number[] = [];
for (const rawGen of rawGeneralList) {
generalList.push(parseInt(rawGen.id as string));
}
let result: InvalidResponse;
try {
const response = await axios({
url: 'j_general_set_permission.php',
method: 'post',
responseType: 'json',
data: convertFormData({
isAmbassador: isAmbassador,
genlist: generalList
})
});
result = response.data;
}
catch (e) {
console.log(e);
alert(`실패했습니다: ${e}`);
return;
}
if (!result.result) {
alert(`변경하지 못했습니다 : ${result.reason}`);
return;
}
alert('변경했습니다.');
location.reload();
}
$(function () {
setAxiosXMLHttpRequest();
$('#selectAmbassador').select2({
theme: 'bootstrap4',
placeholder: "",
allowClear: true,
language: "ko",
width: '300px',
maximumSelectionLength: 2,
containerCss: {
display: "inline-block !important;",
color: 'white !important'
},
data: candidateAmbassadors,
//containerCssClass: 'simple-select2-align-center bg-secondary text-secondary',
dropdownCssClass: 'simple-select2-align-center bg-secondary text-secondary',
});
$('#selectAuditor').select2({
theme: 'bootstrap4',
placeholder: "",
allowClear: true,
language: "ko",
width: '300px',
maximumSelectionLength: 2,
containerCss: {
display: "inline-block !important;",
color: 'white !important'
},
data: candidateAuditors,
//containerCssClass: 'simple-select2-align-center bg-secondary text-secondary',
dropdownCssClass: 'simple-select2-align-center bg-secondary text-secondary',
});
$('#changeAmbassador').on('click', async function (e) {
e.preventDefault();
if (!confirm('외교권자를 변경할까요?')) {
return;
}
await changePermission(true, $('#selectAmbassador').select2('data'));
return;
});
$('#changeAuditor').on('click', async function (e) {
e.preventDefault();
if (!confirm('조언자를 변경할까요?')) {
return;
}
await changePermission(false, $('#selectAuditor').select2('data'));
return;
});
$('#btn_kick').on('click', async function (e) {
e.preventDefault();
const $kickSelect = $('#genlist_kick option:selected');
const generalID = $kickSelect.val();
if (!generalID) {
alert('장수를 선택해주세요');
return;
}
const generalName = $kickSelect.data('name');
const josaUl = JosaUtil.pick(generalName, '을');
if (!confirm(`${generalName}${josaUl} 추방하시겠습니까?`)) {
return;
}
let result: InvalidResponse;
try {
const response = await axios({
url: 'j_myBossInfo.php',
method: 'post',
responseType: 'json',
data: convertFormData({
action: '추방',
destGeneralID: generalID
})
});
result = response.data;
}
catch (e) {
console.log(e);
alert(`실패했습니다: ${e}`);
return;
}
if (!result.result) {
alert(`추방하지 못했습니다. : ${result.reason}`);
return;
}
alert(`${generalName}${josaUl} 추방했습니다.`);
location.reload();
});
$('.btn_appoint').on('click', async function (e) {
e.preventDefault();
const $btn = $(this);
const officerLevel = $btn.data('officer_level');
const officerLevelText = $btn.data('officer_level_text');
let cityID = 0;
let cityName = '_';
const $generalSelect = $(`#genlist_${officerLevel} option:selected`);
const $citySelect = $(`#citylist_${officerLevel} option:selected`);
const generalID = parseInt(unwrap_any<string>($generalSelect.val()));
const generalName = $generalSelect.data('name');
const generalOfficerLevel = $generalSelect.data('officer_level');
if (officerLevel >= 5) {
if (generalID == 0) {
if (!confirm(`${officerLevelText}직을 비우시겠습니까?`)) {
return false;
}
} else if (generalOfficerLevel >= 5) {
const josaUl = JosaUtil.pick(generalName, '을');
if (!confirm(`이미 수뇌인 ${generalName}${josaUl} ${officerLevelText}직에 임명하시겠습니까?`)) {
return false;
}
} else {
const josaUl = JosaUtil.pick(generalName, '을');
if (!confirm(`${generalName}${josaUl} ${officerLevelText}직에 임명하시겠습니까?`)) {
return false;
}
}
} else {
cityID = parseInt(unwrap_any<string>($citySelect.val()));
if (!cityID) {
alert('도시를 선택해주세요');
return false;
}
cityName = $citySelect.find('option:selected .name_field').text();
if (generalID == 0) {
if (!confirm(`${cityName} ${officerLevelText}직을 비우시겠습니까?`)) {
return false;
}
} else if (generalOfficerLevel >= 5) {
const josaUl = JosaUtil.pick(generalName, '을');
if (!confirm(`수뇌인 ${generalName}${josaUl} ${cityName} ${officerLevelText}직에 임명하시겠습니까?`)) {
return false;
}
} else {
const josaUl = JosaUtil.pick(generalName, '을');
if (!confirm(`${generalName}${josaUl} ${cityName} ${officerLevelText}직에 임명하시겠습니까?`)) {
return false;
}
}
}
let result: InvalidResponse;
try {
const response = await axios({
url: 'j_myBossInfo.php',
method: 'post',
responseType: 'json',
data: convertFormData({
action: '임명',
destGeneralID: generalID,
destCityID: cityID,
officerLevel: officerLevel
})
});
result = response.data;
}
catch (e) {
console.log(e);
alert(`실패했습니다: ${e}`);
return;
}
if (!result.result) {
alert(`임명하지 못했습니다. : ${result.reason}`);
return false;
}
if (generalID) {
const josaUl = JosaUtil.pick(generalName, '을');
alert(`${generalName}${josaUl} 임명했습니다.`);
} else {
alert('관직을 비웠습니다.');
}
location.reload();
})
})
+41
View File
@@ -0,0 +1,41 @@
{
"ingame": {
"troop": "troop.ts",
"map": "map.ts",
"install_db": "install_db.ts",
"install": "install.ts",
"battle_simulator": "battle_simulator.ts",
"battleCenter": "battleCenter.ts",
"bestGeneral": "bestGeneral.ts",
"recent_map": "recent_map.ts",
"select_npc": "select_npc.ts",
"betting": "betting.ts",
"bossInfo": "bossInfo.ts",
"myPage": "myPage.ts",
"extExpandCity": "extExpandCity.ts",
"diplomacy": "diplomacy.ts",
"currentCity": "currentCity.ts",
"hallOfFame": "hallOfFame.ts",
"history": "history.ts",
"select_general_from_pool": "select_general_from_pool.ts",
"extKingdoms": "extKingdoms.ts",
"common": "common_deprecated.ts"
},
"ingame_vue": {
"v_auction": "v_auction.ts",
"v_inheritPoint": "v_inheritPoint.ts",
"v_board": "v_board.ts",
"v_cachedMap": "v_cachedMap",
"v_chiefCenter": "v_chiefCenter.ts",
"v_NPCControl": "v_NPCControl.ts",
"v_join": "v_join.ts",
"v_main": "v_main.ts",
"v_history": "v_history.ts",
"v_nationStratFinan": "v_nationStratFinan.ts",
"v_processing": "v_processing.ts",
"v_nationBetting": "v_nationBetting.ts",
"v_nationGeneral": "v_nationGeneral.ts",
"v_globalDiplomacy": "v_globalDiplomacy",
"v_vote": "v_vote.ts"
}
}
+42
View File
@@ -0,0 +1,42 @@
import { exportWindow } from "@util/exportWindow";
import { errUnknown } from "@/common_legacy";
import { initTooltip } from "@/legacy/initTooltip";
import { activateFlip } from "@/legacy/activateFlip";
import { isBrightColor } from "@util/isBrightColor";
import { getIconPath } from "@util/getIconPath";
import { mb_strwidth } from "@util/mb_strwidth";
import { TemplateEngine } from "@util/TemplateEngine";
import { escapeHtml } from "@/legacy/escapeHtml";
import { nl2br } from "@util/nl2br";
import jQuery from "jquery";
import "@scss/common_legacy.scss";
import { insertCustomCSS } from "./util/customCSS";
import { htmlReady } from "./util/htmlReady";
exportWindow(jQuery, '$');
exportWindow(jQuery, 'jQuery');
htmlReady(function(){
initTooltip();
activateFlip();
insertCustomCSS();
})
/**
* {0}, {1}, {2}형태로 포맷해주는 함수
*/
exportWindow(function(this:string, ...args:(string|number)[]){
return this.replace(/{(\d+)}/g, function (match, number) {
return (typeof args[number] != 'undefined') ? args[number].toString() : match;
});
}, 'format', String.prototype);
exportWindow(escapeHtml, 'escapeHtml');
exportWindow(mb_strwidth, 'mb_strwidth');
exportWindow(isBrightColor, 'isBrightColor');
exportWindow(TemplateEngine, 'TemplateEngine');
exportWindow(getIconPath, 'getIconPath');
exportWindow(activateFlip, 'activateFlip');
exportWindow(errUnknown, 'errUnknown');
exportWindow(nl2br, 'nl2br');
exportWindow(initTooltip, 'initTooltip');
+99
View File
@@ -0,0 +1,99 @@
/**
* object의 array를 id를 key로 삼는 object로 재 변환
*/
export function convertDictById<K extends string | number, T extends { id: K }>(arr: ArrayLike<T>): Record<K, T> {
const result: Record<string | number, T> = {};
for (const v of Object.values(arr)) {
result[v.id] = v;
}
return result;
}
/**
* array를 set 형태의 object로 변환
*/
export function convertSet<K extends string | number>(arr: ArrayLike<K>): Record<K, true> {
const result: Record<string | number, true> = {};
for (const v of Object.values(arr)) {
result[v] = true;
}
return result;
}
export function stringFormat(text: string, ...args: (string | number)[]): string {
return text.replace(/{(\d+)}/g, function (match, number) {
return (typeof args[number] != 'undefined') ? args[number].toString() : match;
});
}
/**
* 게임내에서 지원하는 color type만 선택할 수 있도록 해주는 함수
* @param {string} color #AAAAAA 또는 AAAAAA 형태로 작성된 RGB hex color string
* @returns {string}
*/
export function convColorValue(color: string): string {
if (color.charAt(0) == '#') {
color = color.substr(1);
}
color = color.toUpperCase();
const colorBase = new Set([
'000080', '0000FF', '008000', '008080', '00BFFF', '00FF00', '00FFFF', '20B2AA',
'2E8B57', '483D8B', '6495ED', '7B68EE', '7CFC00', '7FFFD4', '800000', '800080',
'808000', '87CEEB', 'A0522D', 'A9A9A9', 'AFEEEE', 'BA55D3', 'E0FFFF', 'F5F5DC',
'FF0000', 'FF00FF', 'FF6347', 'FFA500', 'FFC0CB', 'FFD700', 'FFDAB9', 'FFFF00',
'FFFFFF'
]);
if (!colorBase.has(color)) {
return '000000';
}
return color;
}
//linkify가 불러와 있어야함
declare global {
interface Window {
linkifyStr: (v: string, k?: Record<string, string | number>) => string;
}
}
export function combineObject<K extends string, V>(item: V[], columnList: K[]): Record<K, V> {
const newItem: Record<string, V> = {};
for (const columnIdx in columnList) {
const columnName = columnList[columnIdx];
newItem[columnName] = item[columnIdx];
}
return newItem;
}
export function errUnknown(): void {
alert('작업을 실패했습니다.');
}
/*
function br2nl (text) {
return text.replace(/<\s*\/?br\s*[\/]?>/gi, '\n');
}
*/
export function getNpcColor(npcType: number): 'skyblue' | 'cyan' | 'deepskyblue' | 'darkcyan' | 'mediumaquamarine' | undefined {
if (npcType == 6){
return 'mediumaquamarine';
}
if (npcType == 5){
return 'darkcyan';
}
if (npcType == 4){
return 'deepskyblue';
}
if (npcType >= 2) {
return 'cyan';
}
if (npcType == 1) {
return 'skyblue';
}
return undefined;
}
+7
View File
@@ -0,0 +1,7 @@
interface Window {
pathConfig: {
root: string,
sharedIcon: string,
gameImage: string,
}
}
+393
View File
@@ -0,0 +1,393 @@
<template>
<div class="bg0">
<div class="bg2">거래장</div>
<div style="background-color: orange"> 구매</div>
<div class="auctionItem gx-0">
<div class="idx">번호</div>
<div class="host">판매자</div>
<div class="amount">수량</div>
<div class="highestBidder">입찰자</div>
<div class="highestBid">입찰가</div>
<div class="bidRatio">단가</div>
<div class="finishBid">마감가</div>
<div class="closeDate">거래 종료</div>
</div>
<div
v-for="auction of buyRice"
:key="auction.id"
class="auctionItem gx-0"
@click="selectedBuyRiceAuction = auction"
>
<div class="idx f_tnum">{{ auction.id }}</div>
<div class="host">{{ auction.hostName }}</div>
<div class="amount f_tnum"> {{ auction.amount.toLocaleString() }}</div>
<div class="highestBidder">{{ auction.highestBid?.generalName ?? "-" }}</div>
<div :class="['highestBid f_tnum', auction.highestBid ? '' : 'noBid']">
{{ (auction.highestBid?.amount ?? auction.startBidAmount).toLocaleString() }}
</div>
<div class="bidRatio f_tnum">
{{ auction.highestBid ? (auction.highestBid.amount / auction.amount).toFixed(2) : "-" }}
</div>
<div class="finishBid f_tnum"> {{ auction.finishBidAmount.toLocaleString() }}</div>
<div class="closeDate f_tnum">{{ cutDateTime(auction.closeDate) }}</div>
</div>
<div v-if="selectedBuyRiceAuction !== undefined" class="row gx-1">
<div class="offset-1 col-4 offset-md-3 col-md-2 align-self-center f_tnum text-end">
{{ selectedBuyRiceAuction.id }} {{ selectedBuyRiceAuction.amount }} 경매에
</div>
<div class="col-3 col-md-2">
<NumberInputWithInfo
v-model="bidAmountBuyRiceAuction"
:int="true"
:min="selectedBuyRiceAuction.startBidAmount"
:max="selectedBuyRiceAuction.finishBidAmount"
:step="10"
></NumberInputWithInfo>
</div>
<div class="col-2 col-md-1 d-grid"><BButton @click="bidBuyRiceAuction">입찰</BButton></div>
</div>
<div style="background-color: skyblue"> 판매</div>
<div class="auctionItem gx-0">
<div class="idx">번호</div>
<div class="host">판매자</div>
<div class="amount">수량</div>
<div class="highestBidder">입찰자</div>
<div class="highestBid">입찰가</div>
<div class="bidRatio">단가</div>
<div class="finishBid">마감가</div>
<div class="closeDate">거래 종료</div>
</div>
<div
v-for="auction of sellRice"
:key="auction.id"
class="auctionItem gx-0"
@click="selectedSellRiceAuction = auction"
>
<div class="idx f_tnum">{{ auction.id }}</div>
<div class="host">{{ auction.hostName }}</div>
<div class="amount f_tnum">{{ auction.amount.toLocaleString() }}</div>
<div class="highestBidder">{{ auction.highestBid?.generalName ?? "-" }}</div>
<div :class="['highestBid f_tnum', auction.highestBid ? '' : 'noBid']">
{{ (auction.highestBid?.amount ?? auction.startBidAmount).toLocaleString() }}
</div>
<div class="bidRatio f_tnum">
{{ auction.highestBid ? (auction.highestBid.amount / auction.amount).toFixed(2) : "-" }}
</div>
<div class="finishBid f_tnum"> {{ auction.finishBidAmount.toLocaleString() }}</div>
<div class="closeDate f_tnum">{{ cutDateTime(auction.closeDate) }}</div>
</div>
<div v-if="selectedSellRiceAuction !== undefined" class="row gx-1">
<div class="offset-1 col-4 offset-md-3 col-md-2 align-self-center f_tnum text-end">
{{ selectedSellRiceAuction.id }} {{ selectedSellRiceAuction.amount }} 경매에
</div>
<div class="col-3 col-md-2">
<NumberInputWithInfo
v-model="bidAmountSellRiceAuction"
:int="true"
:min="selectedSellRiceAuction.startBidAmount"
:max="selectedSellRiceAuction.finishBidAmount"
:step="10"
></NumberInputWithInfo>
</div>
<div class="col-2 col-md-1 d-grid"><BButton @click="bidSellRiceAuction">입찰</BButton></div>
</div>
<div>경매 등록</div>
<div class="row gx-1">
<div class="col-2 offset-md-2 col-md-1">
매물<br />
<BButtonGroup>
<BButton :pressed="openAuctionInfo.type == 'buyRice'" @click="openAuctionInfo.type = 'buyRice'"> </BButton>
<BButton :pressed="openAuctionInfo.type == 'sellRice'" @click="openAuctionInfo.type = 'sellRice'">
</BButton>
</BButtonGroup>
</div>
<div class="col col-md-2">
수량 ({{ openAuctionInfo.type == "buyRice" ? "쌀" : "금" }})<br />
<NumberInputWithInfo
v-model="openAuctionInfo.amount"
:int="true"
:min="100"
:max="10000"
:step="10"
></NumberInputWithInfo>
</div>
<div class="col-2 col-md-1">
기간()
<NumberInputWithInfo
v-model="openAuctionInfo.closeTurnCnt"
:int="true"
:min="3"
:max="24"
:step="1"
></NumberInputWithInfo>
</div>
<div class="col col-md-2">
시작가 ({{ openAuctionInfo.type == "buyRice" ? "금" : "쌀" }})
<NumberInputWithInfo
v-model="openAuctionInfo.startBidAmount"
:int="true"
:min="100"
:max="10000"
:step="10"
></NumberInputWithInfo>
</div>
<div class="col col-md-2">
마감가 ({{ openAuctionInfo.type == "buyRice" ? "금" : "쌀" }})
<NumberInputWithInfo
v-model="openAuctionInfo.finishBidAmount"
:int="true"
:min="100"
:max="10000"
:step="10"
></NumberInputWithInfo>
</div>
<div class="col-1 d-grid">
<BButton @click="openAuction">등록</BButton>
</div>
</div>
<div>이전 경매(최근 20)</div>
<div v-for="(log, idx) in recentLogs" :key="idx">
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="formatLog(log)" />
</div>
</div>
</template>
<script lang="ts" setup>
import type { BasicResourceAuctionInfo } from "@/defs/API/Auction";
import { SammoAPI } from "@/SammoAPI";
import { unwrap } from "@/util/unwrap";
import { useToast, BButtonGroup, BButton } from "bootstrap-vue-3";
import { isString } from "lodash";
import { onMounted, reactive, ref, watch } from "vue";
import NumberInputWithInfo from "@/components/NumberInputWithInfo.vue";
import { formatLog } from "@/utilGame/formatLog";
const toasts = unwrap(useToast());
const buyRice = ref<BasicResourceAuctionInfo[]>([]);
const sellRice = ref<BasicResourceAuctionInfo[]>([]);
const recentLogs = ref<string[]>([]);
const selectedBuyRiceAuction = ref<BasicResourceAuctionInfo | undefined>(undefined);
const bidAmountBuyRiceAuction = ref<number>(0);
watch(selectedBuyRiceAuction, (auction) => {
if (!auction) {
return;
}
bidAmountBuyRiceAuction.value = auction.highestBid ? auction.highestBid.amount : auction.startBidAmount;
});
function cutDateTime(dateTime: string, showSecond = false) {
if (showSecond) {
return dateTime.substring(5, 19);
}
return dateTime.substring(5, 16);
}
async function bidBuyRiceAuction() {
if (selectedBuyRiceAuction.value === undefined) {
return;
}
try {
await SammoAPI.Auction.BidBuyRiceAuction({
auctionID: selectedBuyRiceAuction.value.id,
amount: bidAmountBuyRiceAuction.value,
});
toasts.success({
title: "입찰 완료",
body: `입찰했습니다.`,
});
await refresh();
} catch (e) {
console.error(e);
if (isString(e)) {
toasts.danger({
title: "에러",
body: e,
});
}
}
}
const selectedSellRiceAuction = ref<BasicResourceAuctionInfo | undefined>(undefined);
const bidAmountSellRiceAuction = ref<number>(0);
watch(selectedSellRiceAuction, (auction) => {
if (!auction) {
return;
}
bidAmountSellRiceAuction.value = auction.highestBid ? auction.highestBid.amount : auction.startBidAmount;
});
async function bidSellRiceAuction() {
if (selectedSellRiceAuction.value === undefined) {
return;
}
try {
await SammoAPI.Auction.BidSellRiceAuction({
auctionID: selectedSellRiceAuction.value.id,
amount: bidAmountSellRiceAuction.value,
});
toasts.success({
title: "입찰 완료",
body: `입찰했습니다.`,
});
await refresh();
} catch (e) {
console.error(e);
if (isString(e)) {
toasts.danger({
title: "에러",
body: e,
});
}
}
}
type openAuctionT = {
type: "buyRice" | "sellRice";
amount: number;
startBidAmount: number;
finishBidAmount: number;
closeTurnCnt: number;
};
const openAuctionInfo = reactive<openAuctionT>({
type: "buyRice",
amount: 1000,
startBidAmount: 500,
finishBidAmount: 2000,
closeTurnCnt: 24,
});
async function refresh() {
try {
const result = await SammoAPI.Auction.GetActiveResourceAuctionList();
buyRice.value = result.buyRice;
sellRice.value = result.sellRice;
recentLogs.value = result.recentLogs;
} catch (e) {
console.error(e);
if (isString(e)) {
toasts.danger({
title: "에러",
body: e,
});
}
}
}
async function openAuction() {
const { type, amount, startBidAmount, finishBidAmount, closeTurnCnt } = openAuctionInfo;
try {
const apiCall = type === "buyRice" ? SammoAPI.Auction.OpenBuyRiceAuction : SammoAPI.Auction.OpenSellRiceAuction;
const result = await apiCall({
amount,
startBidAmount,
finishBidAmount,
closeTurnCnt,
});
toasts.success({
title: "성공",
body: `${result.auctionID}번 경매로 등록되었습니다.`,
});
await refresh();
} catch (e) {
console.error(e);
if (isString(e)) {
toasts.danger({
title: "에러",
body: e,
});
}
}
}
defineExpose({
refresh,
});
onMounted(async () => {
void refresh();
console.log("mounted");
});
</script>
<style lang="scss" scoped>
@import "@scss/common/break_500px.scss";
.auctionItem {
display: grid;
text-align: center;
> div {
align-self: center;
}
.noBid {
color: #ccc;
}
border-bottom: solid gray 1px;
}
@include media-500px {
.auctionItem {
grid-template-columns: 1fr 3fr 3fr 1fr 2fr 2fr;
grid-template-rows: 1fr 1fr;
.idx {
grid-column: 1 / 2;
grid-row: 1 / 3;
}
.host {
grid-column: 2 / 3;
grid-row: 1 / 2;
}
.amount {
grid-column: 2 / 3;
grid-row: 2/ 3;
}
.highestBidder {
grid-column: 3 / 4;
grid-row: 1 / 2;
}
.highestBid {
grid-column: 3 / 4;
grid-row: 2 / 3;
}
.bidRatio {
grid-column: 4 / 5;
grid-row: 1 / 3;
}
.finishBid {
grid-column: 5 / 6;
grid-row: 1 / 3;
}
.closeDate {
grid-column: 6 / 7;
grid-row: 1 / 3;
}
}
}
@include media-1000px {
.auctionItem {
grid-template-columns: 1fr 2fr 2fr 2fr 2fr 1fr 3fr 2fr;
grid-template-rows: 1fr;
}
}
</style>
+254
View File
@@ -0,0 +1,254 @@
<template>
<div class="bg0">
<div>
가명: <span class="isMe">{{ obfuscatedName }}</span>
</div>
<template v-if="currentAuction !== undefined">
<div class="bg2">경매 {{ currentAuction.auction.id }} 상세</div>
<div class="row gx-0 text-center">
<div class="col-2 col-md-1 bg1">경매명</div>
<div class="col-4 col-md-2">{{ currentAuction.auction.title }}</div>
<div class="col-2 col-md-1 bg1">주최자(익명)</div>
<div :class="['col-4 col-md-2', currentAuction.auction.isCallerHost ? 'isMe' : '']">
{{ currentAuction.auction.hostName }}
</div>
<div class="col-2 col-md-1 bg1">종료일시</div>
<div class="col-4 col-md-2 f_tnum">{{ cutDateTime(currentAuction.auction.closeDate, true) }}</div>
<div class="col-2 col-md-1 bg1">최대지연</div>
<div class="col-4 col-md-2 f_tnum">
{{ cutDateTime(currentAuction.auction.availableLatestBidCloseDate, true) }}
</div>
</div>
<div class="bg1">입찰자 목록</div>
<div
class="row gx-0 px-md-5 text-center"
:style="{
borderBottom: 'solid 1px white',
}"
>
<div class="col-4 offset-md-2 col-md-3">입찰자</div>
<div class="col-4 col-md-2 text-end px-5">입찰포인트</div>
<div class="col-4 col-md-3">시각</div>
</div>
<div v-for="bidder of currentAuction.bidList" :key="bidder.amount" class="row gx-0 px-md-5 text-center">
<div :class="['col-4 offset-md-2 col-md-3', bidder.isCallerHighestBidder ? 'isMe' : '']">{{ bidder.generalName }}</div>
<div class="col-4 col-md-2 text-end px-5 f_tnum">{{ bidder.amount.toLocaleString() }}</div>
<div class="col-4 col-md-3 f_tnum">{{ cutDateTime(bidder.date) }}</div>
</div>
<div class="bg1">입찰하기</div>
<div class="row">
<label class="col-5 offset-md-3 col-md-3 col-form-label text-center">유산포인트 (잔여: {{ currentAuction.remainPoint.toLocaleString() }}포인트)</label>
<div class="col-4 col-md-2">
<NumberInputWithInfo
v-model="bidAmount"
:int="true"
:min="currentAuction.bidList[0].amount"
:max="currentAuction.remainPoint"
title=""
:step="1"
></NumberInputWithInfo>
</div>
<div class="col-3 col-md-1 d-grid"><BButton @click="bidAuction">입찰</BButton></div>
</div>
</template>
<div class="bg1">진행중인 경매 목록</div>
<div
class="row gx-0 text-center"
:style="{
borderBottom: 'solid 1px white',
}"
>
<div class="col-1">번호</div>
<div class="col-4">경매명</div>
<div class="col-1">주최자</div>
<div class="col-2">종료일시</div>
<div class="col-1">연장</div>
<div class="col-1">1순위</div>
<div class="col-2 text-end px-2">포인트</div>
</div>
<div
v-for="[auctionID, auction] of ongoingAuctionList"
:key="auctionID"
class="row gx-0 text-center clickableRow"
@click="currentAuctionID = auctionID"
>
<div class="col-1">{{ auction.id }}</div>
<div class="col-4">{{ auction.title }}</div>
<div :class="['col-1', auction.isCallerHost ? 'isMe' : '']">{{ auction.hostName }}</div>
<div class="col-2 f_tnum">{{ cutDateTime(auction.closeDate) }}</div>
<div class="col-1">{{ auction.remainCloseDateExtensionCnt > 0 ? "남음" : "소진" }}</div>
<div :class="['col-1', auction.highestBid.isCallerHighestBidder ? 'isMe' : '']">
{{ auction.highestBid.generalName }}
</div>
<div class="col-2 text-end px-2 f_tnum">{{ auction.highestBid.amount.toLocaleString() }}</div>
</div>
<div class="bg1">종료된 경매 목록</div>
<div
class="row gx-0 text-center"
:style="{
borderBottom: 'solid 1px white',
}"
>
<div class="col-1">번호</div>
<div class="col-4">경매명</div>
<div class="col-1">주최자</div>
<div class="col-2">종료일시</div>
<div class="col-1">연장</div>
<div class="col-1">1순위</div>
<div class="col-2 text-end px-2">포인트</div>
</div>
<div
v-for="[auctionID, auction] of finishedAuctionList"
:key="auctionID"
class="row gx-0 text-center clickableRow"
@click="currentAuctionID = auctionID"
>
<div class="col-1">{{ auction.id }}</div>
<div class="col-4">{{ auction.title }}</div>
<div :class="['col-1', auction.isCallerHost ? 'isMe' : '']">{{ auction.hostName }}</div>
<div class="col-2 f_tnum">{{ cutDateTime(auction.closeDate) }}</div>
<div class="col-1">{{ auction.remainCloseDateExtensionCnt > 0 ? "남음" : "소진" }}</div>
<div :class="['col-1', auction.highestBid.isCallerHighestBidder ? 'isMe' : '']">
{{ auction.highestBid.generalName }}
</div>
<div class="col-2 text-end px-2 f_tnum">{{ auction.highestBid.amount.toLocaleString() }}</div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { UniqueItemAuctionDetail, UniqueItemAuctionList } from "@/defs/API/Auction";
import { SammoAPI } from "@/SammoAPI";
import { unwrap } from "@/util/unwrap";
import { useToast, BButton } from "bootstrap-vue-3";
import { isString } from "lodash";
import { onMounted, ref, watch } from "vue";
import NumberInputWithInfo from "@/components/NumberInputWithInfo.vue";
type AuctionItemInfo = UniqueItemAuctionList["list"][0];
const currentAuctionID = ref<number>();
const currentAuction = ref<UniqueItemAuctionDetail | undefined>(undefined);
const bidAmount = ref<number>(5000);
async function refreshDetail() {
if (currentAuctionID.value === undefined) {
return;
}
const auctionID = currentAuctionID.value;
try {
currentAuction.value = await SammoAPI.Auction.GetUniqueItemAuctionDetail({ auctionID });
} catch (e) {
console.error(e);
if (isString(e)) {
unwrap(useToast()).danger({
title: "에러",
body: e,
});
}
}
}
watch(currentAuctionID, () => {
void refreshDetail();
});
const ongoingAuctionList = ref(new Map<number, AuctionItemInfo>());
const finishedAuctionList = ref(new Map<number, AuctionItemInfo>());
const obfuscatedName = ref("");
const toasts = unwrap(useToast());
function cutDateTime(dateTime: string, showSecond = false) {
if (showSecond) {
return dateTime.substring(5, 19);
}
return dateTime.substring(5, 16);
}
async function refreshList() {
try {
const result = await SammoAPI.Auction.GetUniqueItemAuctionList();
obfuscatedName.value = result.obfuscatedName;
finishedAuctionList.value = new Map(
result.list.filter((auction) => auction.finished).map((auction) => [auction.id, auction])
);
ongoingAuctionList.value = new Map(
result.list.filter((auction) => !auction.finished).map((auction) => [auction.id, auction])
);
if (currentAuctionID.value === undefined && ongoingAuctionList.value.size > 0) {
const auctionIterator = ongoingAuctionList.value.values().next();
if (!auctionIterator.done) {
currentAuctionID.value = auctionIterator.value.id;
bidAmount.value = auctionIterator.value.highestBid.amount;
}
}
} catch (e) {
console.error(e);
if (isString(e)) {
toasts.danger({
title: "에러",
body: e,
});
}
}
}
async function bidAuction() {
if (currentAuction.value === undefined) {
return;
}
const amount = bidAmount.value;
const auctionInfo = currentAuction.value.auction;
if (confirm(`${auctionInfo.title}${amount}유산포인트를 입찰하시겠습니까?`)) {
try {
await SammoAPI.Auction.BidUniqueAuction({ auctionID: auctionInfo.id, amount });
toasts.success({
title: "성공",
body: "입찰이 완료되었습니다.",
});
await refresh();
} catch (e) {
console.error(e);
if (isString(e)) {
toasts.danger({
title: "에러",
body: e,
});
}
}
}
}
async function refresh() {
const waiters = [refreshList(), refreshDetail()];
await Promise.all(waiters);
}
defineExpose({
refresh,
});
onMounted(() => {
void refreshList();
});
</script>
<style>
.isMe {
font-weight: bold;
color: aquamarine;
}
.clickableRow{
cursor: pointer;
}
.clickableRow:hover{
background-color: rgba(255, 255, 255, 0.3);
}
</style>
+460
View File
@@ -0,0 +1,460 @@
<template>
<div v-if="bettingDetailInfo !== undefined && info !== undefined">
<div class="bg2">
{{ info.name }}
<span v-if="info.finished">(종료)</span>
<span v-else-if="(yearMonth ?? 0) <= info.closeYearMonth"
>({{ parseYearMonth(info.closeYearMonth)[0] }} {{ parseYearMonth(info.closeYearMonth)[1] }}월까지)</span
>
<span v-else>(베팅 마감)</span>
(총액: {{ bettingAmount.toLocaleString() }})
</div>
<div class="row bettingCandidates gx-1 gy-1">
<div v-for="(candidate, idx) in info.candidates" :key="idx" class="col-4 col-md-2" @click="toggleCandidate(parseInt(idx))">
<div
:class="[
'bettingCandidate',
pickedBetType.has(parseInt(idx)) ? 'picked' : undefined,
info.finished && winner.has(parseInt(idx)) ? 'picked' : undefined,
]"
>
<div class="title bg1">
{{ candidate.title }}
</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-if="candidate.isHtml" class="info" v-html="candidate.info" />
<div v-else class="info">{{ candidate.info }}</div>
<div class="pickRate">선택율: {{ (((partialBet.get(parseInt(idx)) ?? 0) / pureBettingAmount) * 100).toFixed(1) }}%</div>
</div>
</div>
</div>
<div v-if="!info.finished && (yearMonth ?? 0) <= info.closeYearMonth" class="row gx-0">
<div class="col-6 col-md-3 align-self-center">
잔여 {{ info.reqInheritancePoint ? "포인트" : "금" }} : {{ bettingDetailInfo.remainPoint.toLocaleString() }}
</div>
<div class="col-6 col-md-3 align-self-center">
사용 포인트: {{ sum(Array.from(myBettings.values())).toLocaleString() }}
</div>
<div class="col-6 col-md-3 align-self-center">대상: {{ getTypeStr(pickedBetTypeKey) }}</div>
<div class="col-4 col-md-2 d-grid">
<!-- eslint-disable-next-line vue/max-attributes-per-line -->
<b-form-input v-model.number="betPoint" class="d-grid" type="number" :min="10" :max="1000" :step="10" />
</div>
<div class="col-2 col-md-1 d-grid">
<b-button class="d-grid" @click="submitBet"> 베팅 </b-button>
</div>
</div>
<div>
<div class="bg2">배당 순위</div>
<div
class="row"
:style="{
borderBottom: 'gray solid 1px',
}"
>
<div class="col-5 text-center">대상</div>
<div class="col-2 text-center">베팅액</div>
<div class="col-3 text-center"> 베팅</div>
<div class="col-2 text-center">
{{ info.finished ? "배율" : "기대 배율" }}
</div>
</div>
<template v-if="info.finished">
<div v-for="[betType, amount] of detailBet" :key="betType" class="row">
<template v-for="[matchPoint, color] of [calcMatchPointWithColor(betType)]" :key="matchPoint">
<div
class="col-5"
:style="{
fontWeight: myBettings.has(betType) ? 'bold' : undefined,
color: color,
}"
>
{{ getTypeStr(betType) }}
</div>
<div class="col-2 text-end">
{{ amount.toLocaleString() }}
</div>
<div v-if="myBettings.has(betType)" class="col-3 text-center">
<template v-for="subPoint of [myBettings.get(betType) ?? 0]">
({{ subPoint.toLocaleString() }} ->
{{
calculatedReward[matchPoint] == 0
? 0
: ((subPoint * calculatedReward[matchPoint]) / (calculatedSubAmount.get(matchPoint) ?? 1))
.toFixed(1)
.toLocaleString()
}})
</template>
</div>
<div v-else class="col-3 text-center" />
<div class="col-2 text-end">
{{
(calculatedReward[matchPoint] == 0
? 0
: calculatedReward[matchPoint] / (calculatedSubAmount.get(matchPoint) ?? 1)
)
.toFixed(1)
.toLocaleString()
}}
</div>
</template>
</div>
</template>
<template v-else>
<div v-for="[betType, amount] of detailBet" :key="betType" class="row">
<div
class="col-5"
:style="{
fontWeight: myBettings.has(betType) ? 'bold' : undefined,
}"
>
{{ getTypeStr(betType) }}
</div>
<div class="col-2 text-end">
{{ amount.toLocaleString() }}
</div>
<div v-if="myBettings.has(betType)" class="col-3 text-center">
<template v-for="subPoint of [myBettings.get(betType) ?? 0]">
({{ subPoint.toLocaleString() }} ->
{{ ((subPoint * maxBettingReward) / amount).toFixed(1).toLocaleString() }})
</template>
</div>
<div v-else class="col-3 text-center" />
<div class="col-2 text-end">{{ (maxBettingReward / amount).toFixed(1).toLocaleString() }}</div>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import type { ToastType } from "@/defs";
import type { BettingDetailResponse, BettingInfo } from "@/defs/API/Betting";
import { SammoAPI } from "@/SammoAPI";
import { joinYearMonth } from "@/util/joinYearMonth";
import { parseYearMonth } from "@/util/parseYearMonth";
import { isString, range, sum } from "lodash";
import { ref, type PropType, watch } from "vue";
const props = defineProps({
bettingID: {
type: Number as PropType<number>,
required: true,
},
});
const emit = defineEmits<{
(event: "reqToast", content: ToastType): void;
}>();
const year = ref<number>(0);
const month = ref<number>(0);
const yearMonth = ref<number>(0);
const bettingDetailInfo = ref<BettingDetailResponse>();
const info = ref<BettingInfo>();
const bettingAmount = ref<number>(0);
const maxBettingReward = ref<number>(1);
const pureBettingAmount = ref<number>(0);
const partialBet = ref(new Map<number, number>());
const detailBet = ref<[string, number][]>([]);
const typeMap = ref(new Map<string, string>());
function getTypeStr(type: string): string {
const typeResult = typeMap.value.get(type);
if (typeResult !== undefined) {
return typeResult;
}
const bettingSubTypes = JSON.parse(type) as number[];
if (bettingSubTypes[0] < -1) {
return "Invalid";
}
const textBettingType = bettingSubTypes
.map((idx) => {
return bettingDetailInfo.value?.bettingInfo.candidates[idx].title;
})
.join(", ");
typeMap.value.set(type, textBettingType);
return textBettingType;
}
const pickedBetType = ref(new Set<number>());
const pickedBetTypeKey = ref("[]");
const betPoint = ref(0);
const myBettings = ref(new Map<string, number>());
const winner = ref(new Set<number>());
const calculatedReward = ref<number[]>([]);
const calculatedSubAmount = ref(new Map<number, number>());
function calcMatchPointWithColor(type: string): [number, "green" | "yellow" | "red" | undefined] {
if (!info.value?.finished) {
return [0, undefined];
}
const bettingSubTypes = JSON.parse(type) as number[];
if (bettingSubTypes[0] < -1) {
return [0, undefined];
}
let matchPoint = 0;
for (const subType of bettingSubTypes) {
if (winner.value.has(subType)) {
matchPoint += 1;
}
}
if (info.value.isExclusive) {
if (matchPoint == info.value.selectCnt) {
return [matchPoint, "green"];
} else {
return [matchPoint, "red"];
}
}
let color: "green" | "red" | "yellow" = "green";
if (matchPoint == 0) {
color = "red";
} else if (matchPoint < info.value.selectCnt) {
color = "yellow";
}
return [matchPoint, color];
}
function calcReward() {
if (info.value === undefined || bettingDetailInfo.value === undefined) {
throw "no info";
}
const selectCnt = info.value.selectCnt;
const rewardAmount = new Array<number>(selectCnt).fill(0);
const subAmount = new Map<number, number>();
for (const [bettingTypeStr, amount] of bettingDetailInfo.value.bettingDetail) {
if (amount == 0) {
continue;
}
const [matchPoint] = calcMatchPointWithColor(bettingTypeStr);
subAmount.set(matchPoint, (subAmount.get(matchPoint) ?? 0) + amount);
}
calculatedSubAmount.value = subAmount;
if (selectCnt == 1) {
rewardAmount[selectCnt - 1] = bettingAmount.value;
calculatedReward.value = rewardAmount;
return;
}
if (info.value.isExclusive) {
rewardAmount[selectCnt - 1] = bettingAmount.value;
calculatedReward.value = rewardAmount;
return;
}
let remainRewardAmount = bettingAmount.value;
let accumulatedRewardAmount = 0;
let givenRewardAmount = bettingAmount.value;
for (const matchPoint of range(selectCnt, 0, -1)) {
givenRewardAmount /= 2;
accumulatedRewardAmount += givenRewardAmount;
if (!subAmount.has(matchPoint)) {
continue;
}
rewardAmount[matchPoint] = accumulatedRewardAmount;
remainRewardAmount -= accumulatedRewardAmount;
accumulatedRewardAmount = 0;
}
//남은 상금은 '당첨자'에게 몰아준다.
//당첨자가 아무도 없다면, 0개 맞춘 그룹에게 돌아간다.
for (const matchPoint of range(selectCnt, -1, -1)) {
if (!subAmount.has(matchPoint)) {
continue;
}
rewardAmount[matchPoint] += remainRewardAmount;
break;
}
calculatedReward.value = rewardAmount;
}
async function loadBetting(bettingID: number) {
try {
const result = await SammoAPI.Betting.GetBettingDetail[bettingID]();
year.value = result.year;
month.value = result.month;
yearMonth.value = joinYearMonth(result.year, result.month);
bettingDetailInfo.value = result;
info.value = result.bettingInfo;
typeMap.value.clear();
partialBet.value.clear();
const betSort = new Map<string, number>();
let _bettingAmount = 0;
let adminBettingAmount = 0;
for (const [bettingType, amount] of result.bettingDetail) {
console.log(amount, typeof amount);
let userBet = true;
const bettingSubTypes = JSON.parse(bettingType) as number[];
for (const bettingSubType of bettingSubTypes) {
if (bettingSubType < 0) {
userBet = false;
continue;
}
const oldValue = partialBet.value.get(bettingSubType) ?? 0;
partialBet.value.set(bettingSubType, oldValue + amount);
}
if (userBet) {
const oldValue = betSort.get(bettingType) ?? 0;
betSort.set(bettingType, oldValue + amount);
}
_bettingAmount += amount;
if (!userBet) {
adminBettingAmount += amount;
}
}
console.log(_bettingAmount);
bettingAmount.value = _bettingAmount;
pureBettingAmount.value = _bettingAmount - adminBettingAmount;
if (info.value.isExclusive || info.value.selectCnt == 1) {
maxBettingReward.value = _bettingAmount;
} else {
maxBettingReward.value = _bettingAmount / 2;
}
detailBet.value = Array.from(betSort.entries());
detailBet.value.sort(([, lhsVal], [, rhsVal]) => {
return rhsVal - lhsVal;
});
pickedBetType.value.clear();
pickedBetTypeKey.value = "[]";
myBettings.value.clear();
if (result.bettingInfo.winner) {
winner.value = new Set(result.bettingInfo.winner);
} else {
winner.value.clear();
}
for (const [betType, amount] of result.myBetting) {
myBettings.value.set(betType, amount);
}
calcReward();
} catch (e) {
if (isString(e)) {
emit("reqToast", {
content: {
title: "에러",
body: e,
},
options: {
variant: "danger",
},
});
}
console.error(e);
}
}
void loadBetting(props.bettingID);
watch(
() => props.bettingID,
(newBettingID) => {
void loadBetting(newBettingID);
}
);
function toggleCandidate(idx: number) {
if (info.value === undefined) {
return;
}
if (bettingDetailInfo.value === undefined) {
return;
}
if (info.value.closeYearMonth < yearMonth.value) {
return;
}
if (info.value.finished) {
return;
}
const selectCnt = bettingDetailInfo.value.bettingInfo.selectCnt;
if (selectCnt == 1) {
pickedBetType.value.clear();
pickedBetType.value.add(idx);
pickedBetTypeKey.value = JSON.stringify([idx]);
return;
}
if (pickedBetType.value.has(idx)) {
pickedBetType.value.delete(idx);
} else if (pickedBetType.value.size < selectCnt) {
pickedBetType.value.add(idx);
} else {
emit("reqToast", {
content: {
title: "오류",
body: `이미 ${selectCnt}개를 선택했습니다.`,
},
options: {
variant: "warning",
},
});
return;
}
const typeArr = Array.from(pickedBetType.value.values());
pickedBetTypeKey.value = JSON.stringify(typeArr.sort((lhs, rhs) => lhs - rhs));
}
async function submitBet(): Promise<void> {
const bettingInfo = info.value;
if (bettingInfo === undefined) {
return;
}
const bettingID = bettingInfo.id;
const bettingType = JSON.parse(pickedBetTypeKey.value) as number[];
const amount = betPoint.value;
try {
await SammoAPI.Betting.Bet({
bettingID,
bettingType,
amount,
});
emit("reqToast", {
content: {
title: "완료",
body: "베팅했습니다",
},
options: {
variant: "success",
},
});
await loadBetting(bettingInfo.id);
} catch (e) {
if (isString(e)) {
emit("reqToast", {
content: {
title: "에러",
body: e,
},
options: {
variant: "danger",
},
});
}
console.error(e);
}
}
</script>
+105
View File
@@ -0,0 +1,105 @@
<template>
<div class="articleFrame bg0">
<div class="bg1 row gx-0">
<div class="authorName center">
{{ article.author }}
</div>
<div class="col articleTitle center">
{{ article.title }}
</div>
<div class="col-2 col-md-1 date center">
{{ article.date.slice(5, 16) }}
</div>
</div>
<div class="row gx-0 s-border-b">
<div class="col-2 col-md-1 authorIcon center">
<img class="generalIcon" width="64" height="64" :src="article.author_icon" />
</div>
<div class="col text">
{{ article.text }}
</div>
</div>
<div class="commentList">
<board-comment v-for="comment in article.comment" :key="comment.no" :comment="comment" />
</div>
<div class="row gx-0">
<div class="bg2 inputCommentHeader center d-grid">
<div class="align-self-center">댓글 달기</div>
</div>
<div class="col d-grid">
<input
v-model.trim="newCommentText"
class="commentText"
type="text"
maxlength="250"
placeholder="새 댓글 내용"
@keyup.enter="submitComment"
/>
</div>
<div class="col-2 col-md-1 d-grid">
<b-button class="submitComment" size="sm" @click="submitComment"> 등록 </b-button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { BoardArticleItem } from "@/PageBoard.vue";
import BoardComment from "@/components/BoardComment.vue";
import { ref, type PropType } from "vue";
import axios from "axios";
import { convertFormData } from "@util/convertFormData";
import type { InvalidResponse } from "@/defs";
const newCommentText = ref("");
const props = defineProps({
article: {
type: Object as PropType<BoardArticleItem>,
required: true,
},
});
const emit = defineEmits<{
(event: "submit-comment"): void;
}>();
async function submitComment() {
const comment = newCommentText.value;
if (!comment) {
return;
}
const articleNo = props.article.no;
let result: InvalidResponse;
try {
const response = await axios({
url: "j_board_comment_add.php",
method: "post",
responseType: "json",
data: convertFormData({
articleNo: articleNo,
text: comment,
}),
});
result = response.data;
if (!result.result) {
throw result.reason;
}
} catch (e) {
console.error(e);
alert(`실패했습니다: ${e}`);
return;
}
newCommentText.value = "";
emit("submit-comment");
}
</script>
<style>
td.text {
white-space: pre;
}
</style>
+31
View File
@@ -0,0 +1,31 @@
<template>
<div class="row gx-0 comment s-border-b">
<div class="authorName center d-grid">
<div class="align-self-center">
{{ comment.author }}
</div>
</div>
<div class="col text">
{{ comment.text }}
</div>
<div class="col-2 col-md-1 date center d-grid">
<div class="align-self-center">
{{ comment.date.slice(5, 16) }}
</div>
</div>
</div>
</template>
<script lang="ts">
import type { BoardCommentItem } from "@/PageBoard.vue";
import { defineComponent, type PropType } from "vue";
export default defineComponent({
name: "BoardComment",
props: {
comment: {
type: Object as PropType<BoardCommentItem>,
required: true,
},
},
});
</script>
+32
View File
@@ -0,0 +1,32 @@
<template>
<div class="bg0" style="padding-top: 20px">
<button type="button" class="btn btn-sammo-base2 back_btn" @click="back">
{{ props.type == "close" ? " 닫기" : "돌아가기" }}
</button>
<div />
</div>
</template>
<script lang="ts" setup>
import type { PropType } from "vue";
import "@scss/game_bg.scss";
const props = defineProps({
type: {
type: String as PropType<"normal" | "chief" | "close">,
default: "normal",
required: false,
},
});
function back() {
if (props.type === "normal") {
location.href = "./";
} else if (props.type == "chief") {
location.href = "v_chiefCenter.php";
} else {
//TODO: window.close하려면 부모창이 있어야함!
window.close();
}
}
</script>
+969
View File
@@ -0,0 +1,969 @@
<template>
<div class="commandBox">
<div class="only1000px bg1 center row gx-0" style="height: 24px; font-size: 1.2em">
<div class="col-5 align-self-center text-end">{{ officer.officerLevelText }} :</div>
<div
class="col-7 align-self-center"
:style="{
color: getNpcColor(officer.npcType ?? 0),
}"
>
{{ officer.name }}
</div>
</div>
<div :class="['row', 'controlPad', props.targetIsMe ? 'targetIsMe' : 'targetIsNotMe']">
<div class="col-3 col-md-12 order-md-last">
<div class="d-grid mb-1 py-1 only500px bg1 center">
<div
:style="{
color: getNpcColor(officer.npcType ?? 0),
fontSize: '1.2em',
}"
>
{{ officer.name }}
</div>
<div>{{ officer.officerLevelText }}</div>
</div>
<div class="row gx-1 gy-1 py-1">
<div class="col-md-4 mx-0 mb-0 mt-1 d-grid">
<div class="alert alert-primary mb-0 center" style="padding: 0.5rem 0">
<SimpleClock :serverTime="parseTime(props.date)" />
</div>
</div>
<div class="col-md-4 d-grid">
<BButton variant="secondary" @click="isEditMode = !isEditMode">
{{ isEditMode ? "일반 모드" : "고급 모드" }}
</BButton>
</div>
<BDropdown class="col-md-4" text="반복">
<BDropdownItem v-for="turnIdx in maxPushTurn" :key="turnIdx" @click="repeatNationCommand(turnIdx)">
{{ turnIdx }}
</BDropdownItem>
</BDropdown>
<template v-if="isEditMode">
<BDropdown class="col-md-4" left text="범위">
<BDropdownItem @click="queryActionHelper.selectTurn()"> 해제 </BDropdownItem>
<BDropdownItem @click="queryActionHelper.selectAll()"> 모든턴 </BDropdownItem>
<BDropdownItem @click="queryActionHelper.selectStep(0, 2)"> 홀수턴 </BDropdownItem>
<BDropdownItem @click="queryActionHelper.selectStep(1, 2)"> 짝수턴 </BDropdownItem>
<BDropdownDivider />
<BDropdownText v-for="spanIdx in [3, 4, 5, 6, 7]" :key="spanIdx">
{{ spanIdx }} 간격
<br />
<BButtonGroup>
<BButton
v-for="beginIdx in spanIdx"
:key="beginIdx"
class="ignoreMe"
@click="queryActionHelper.selectStep(beginIdx - 1, spanIdx)"
>
{{ beginIdx }}
</BButton>
</BButtonGroup>
</BDropdownText>
</BDropdown>
<BDropdown class="col-md-4" left text="보관함">
<BDropdownItem
v-for="[actionKey, actions] of storedActions"
:key="actionKey"
@click.self="useStoredAction(actions)"
>
{{ actionKey }}
<BButton size="sm" @click.prevent="deleteStoredActions(actionKey)"> 삭제 </BButton>
</BDropdownItem>
</BDropdown>
<div class="col-md-4 d-grid">
<BDropdown right text="최근">
<BDropdownItem
v-for="(action, idx) in Array.from(recentActions.values()).reverse()"
:key="idx"
@click="void reserveCommandDirect([[queryActionHelper.getSelectedTurnList(), action]])"
>
{{ action.brief }}
</BDropdownItem>
</BDropdown>
</div>
</template>
<BDropdown class="col-md-6" split text="당기기" @click="pullNationCommandSingle">
<BDropdownItem v-for="turnIdx in maxPushTurn" :key="turnIdx" @click="pushNationCommand(-turnIdx)">
{{ turnIdx }}
</BDropdownItem>
</BDropdown>
<BDropdown class="col-md-6" split text="미루기" @click="pushNationCommandSingle">
<BDropdownItem v-for="turnIdx in maxPushTurn" :key="turnIdx" @click="pushNationCommand(turnIdx)">
{{ turnIdx }}
</BDropdownItem>
</BDropdown>
</div>
</div>
<div class="col">
<div :style="{ position: 'relative' }">
<div
class="commandQuickReserveFormAnchor bg-dark"
:style="{
position: 'absolute',
top: `${basicModeRowHeight * currentQuickReserveTarget + 26}px`,
width: '100%',
zIndex: 9,
}"
>
<CommandSelectForm
ref="commandQuickReserveForm"
v-model:activatedCategory="activatedCategory"
:commandList="commandList"
:hideClose="false"
class="bg-dark"
style="position: absolute"
@onClose="chooseQuickReserveCommand($event)"
/>
</div>
</div>
<div class="commandPad chiefReservedCommand">
<div :class="['commandTable', isEditMode ? 'editMode' : 'singleMode']">
<DragSelect
v-slot="{ selected }"
:style="rowGridStyle"
attribute="turnIdx"
:disabled="!isEditMode"
@dragStart="isDragSingle = true"
@dragDone="
isDragSingle = false;
queryActionHelper.selectTurn(...$event);
"
>
<div
v-for="(turnObj, turnIdx) in reservedCommandList"
:key="turnIdx"
:turnIdx="turnIdx"
class="time_pad center f_tnum"
:style="{
backgroundColor: 'black',
whiteSpace: 'nowrap',
overflow: 'hidden',
color: isDragSingle && selected.has(`${turnIdx}`) ? 'cyan' : undefined,
}"
>
{{ turnObj.time }}
</div>
</DragSelect>
<DragSelect
v-slot="{ selected }"
:style="{ ...rowGridStyle, display: isEditMode ? 'grid' : 'none' }"
attribute="turnIdx"
:disabled="!isEditMode"
@dragStart="isDragToggle = true"
@dragDone="
isDragToggle = false;
toggleTurn(...$event);
"
>
<div
v-for="(turnObj, turnIdx) in reservedCommandList"
:key="turnIdx"
:turnIdx="turnIdx"
class="idx_pad center d-grid"
>
<BButton
size="sm"
:variant="
isDragToggle && selected.has(`${turnIdx}`)
? 'light'
: selectedTurnList.has(turnIdx)
? 'info'
: selectedTurnList.size == 0 && prevSelectedTurnList.has(turnIdx)
? 'success'
: 'primary'
"
>
{{ turnIdx + 1 }}
</BButton>
</div>
</DragSelect>
<div :style="rowGridStyle">
<div v-for="(turnObj, turnIdx) in reservedCommandList" :key="turnIdx" class="turn_pad center">
<span v-b-tooltip.hover class="turn_text" :style="turnObj.style" :title="turnObj.tooltip">
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-html="turnObj.brief" />
</span>
</div>
</div>
<div :style="{ ...rowGridStyle, display: isEditMode ? 'none' : 'grid' }">
<div v-for="turnIdx in range(props.maxTurn)" :key="turnIdx" class="action_pad d-grid">
<BButton
:variant="turnIdx % 2 == 0 ? 'secondary' : 'dark'"
size="sm"
class="simple_action_btn bi bi-pencil"
@click="toggleQuickReserveForm(turnIdx)"
/>
</div>
</div>
</div>
<div style="position: relative">
<CommandSelectForm
ref="commandSelectForm"
v-model:activatedCategory="activatedCategory"
:commandList="commandList"
class="bg-dark"
:style="{ position: 'absolute', bottom: '0' }"
@onClose="chooseCommand($event)"
/>
</div>
<div v-if="isEditMode" class="row gx-0">
<div class="col-5 col-md-6 d-grid">
<BDropdown left variant="light" :style="{ color: 'black' }" text="선택한 턴을">
<BDropdownItem @click="clipboardCut"> <i class="bi bi-scissors" />&nbsp;잘라내기 </BDropdownItem>
<BDropdownItem @click="clipboardCopy"> <i class="bi bi-files" />&nbsp;복사하기 </BDropdownItem>
<BDropdownItem @click="clipboardPaste">
<i class="bi bi-clipboard-fill" />&nbsp;붙여넣기
</BDropdownItem>
<BDropdownDivider />
<BDropdownItem @click="setStoredActions">
<i class="bi bi-bookmark-plus-fill" />&nbsp;보관하기
</BDropdownItem>
<BDropdownItem @click="subRepeatCommand">
<i class="bi bi-arrow-repeat" />&nbsp;반복하기
</BDropdownItem>
<BDropdownDivider />
<BDropdownItem @click="eraseSelectedTurnList"> <i class="bi bi-eraser" />&nbsp;비우기 </BDropdownItem>
<BDropdownItem @click="eraseAndPullCommand">
<i class="bi bi-arrow-bar-up" />&nbsp;지우고 당기기
</BDropdownItem>
<BDropdownItem @click="pushEmptyCommand">
<i class="bi bi-arrow-bar-down" />&nbsp;뒤로 밀기
</BDropdownItem>
<!-- 최근에 실행한 10 -->
</BDropdown>
</div>
<div class="col-7 col-md-6 d-grid">
<BButton variant="info" @click="toggleForm($event)"> 명령 선택 </BButton>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import addMinutes from "date-fns/esm/addMinutes";
import { stringifyUrl } from "query-string";
import { onMounted, ref, watch, type PropType, inject } from "vue";
import { formatTime } from "@util/formatTime";
import { joinYearMonth } from "@util/joinYearMonth";
import { mb_strwidth } from "@util/mb_strwidth";
import { parseTime } from "@util/parseTime";
import { parseYearMonth } from "@util/parseYearMonth";
import { convertSearch초성 } from "@util/convertSearch초성";
import VueTypes from "vue-types";
import DragSelect from "@/components/DragSelect.vue";
import { isString, range, trim } from "lodash";
import { SammoAPI } from "@/SammoAPI";
import type { CommandItem, TurnObj } from "@/defs";
import { QueryActionHelper } from "@/util/QueryActionHelper";
import type { Args } from "@/processing/args";
import type { StoredActionsHelper } from "@/util/StoredActionsHelper";
import { getNpcColor } from "@/common_legacy";
import { BButton, BDropdownItem, BDropdownText, BButtonGroup, BDropdownDivider, BDropdown } from "bootstrap-vue-3";
import CommandSelectForm from "@/components/CommandSelectForm.vue";
import SimpleClock from "@/components/SimpleClock.vue";
import type { ChiefResponse } from "@/defs/API/NationCommand";
type TurnObjWithTime = TurnObj & {
time: string;
year?: number;
month?: number;
tooltip?: string;
style?: Record<string, unknown>;
};
const props = defineProps({
maxTurn: VueTypes.integer.isRequired,
maxPushTurn: VueTypes.integer.isRequired,
date: VueTypes.string.isRequired,
year: VueTypes.integer.isRequired,
month: VueTypes.integer.isRequired,
turnTerm: VueTypes.integer.isRequired,
turnTime: VueTypes.string.isRequired,
targetIsMe: VueTypes.bool.isRequired,
selectedTurn: {
type: Object as PropType<Set<number>>,
required: false,
default: () => new Set(),
},
turn: {
type: Array as PropType<TurnObj[]>,
required: true,
},
commandList: {
type: Object as PropType<ChiefResponse["commandList"]>,
required: true,
},
officer: {
type: Object as PropType<ChiefResponse["chiefList"][0]>,
required: true,
},
});
const basicModeRowHeight = 30;
const listReqArgCommand = new Set<string>();
for (const commandCategories of props.commandList) {
if (!commandCategories.values) {
continue;
}
for (const commandObj of commandCategories.values) {
if (!commandObj.reqArg) {
continue;
}
listReqArgCommand.add(commandObj.value);
}
}
const selectedCommand = ref(props.commandList[0].values[0]);
for (const subCategory of props.commandList) {
for (const command of subCategory.values) {
if (command.searchText) {
continue;
}
command.searchText = convertSearch초성(command.simpleName).join("|");
}
}
const invCommandMap: Record<string, CommandItem> = {};
for (const category of props.commandList) {
for (const command of category.values) {
invCommandMap[command.value] = command;
}
}
const rowGridStyle = ref({
display: "grid",
gridTemplateRows: `repeat(${props.maxTurn}, 30px)`,
});
const updated = ref(false);
const isDragSingle = ref(false);
const isDragToggle = ref(false);
const autorun_limit = ref<number | null>(null);
const emit = defineEmits<{
(event: "raise-reload"): void;
(event: "update:selectedTurn", value: Set<number>): void;
}>();
function triggerUpdateCommandList(type?: string) {
console.log("try update", type);
updated.value = false;
setTimeout(() => {
updateCommandList();
}, 1);
}
function toggleTurn(...reqTurnList: number[] | string[]) {
for (let turnIdx of reqTurnList) {
if (isString(turnIdx)) {
turnIdx = parseInt(turnIdx);
}
if (selectedTurnList.value.has(turnIdx)) {
selectedTurnList.value.delete(turnIdx);
} else {
selectedTurnList.value.add(turnIdx);
}
}
emit("update:selectedTurn", selectedTurnList.value);
}
function isDropdownChildren(e?: Event): boolean {
if (!e) {
return false;
}
if (!e.target) {
return false;
}
if (
(e.target as HTMLElement).classList.contains("dropdown-item") ||
(e.target as HTMLElement).classList.contains("dropdown-toggle-split") ||
(e.target as HTMLElement).classList.contains("ignoreMe")
) {
return true;
}
return false;
}
async function repeatNationCommand(amount: number) {
try {
await SammoAPI.NationCommand.RepeatCommand({ amount });
} catch (e) {
console.error(e);
alert(`실패했습니다: ${e}`);
return;
}
emit("raise-reload");
}
function pushNationCommandSingle(e: Event) {
//NOTE: split 구현에 버그가 있어서, 수동으로 구분해야함
if (isDropdownChildren(e)) {
return;
}
void pushNationCommand(1);
}
function pullNationCommandSingle(e: Event) {
//NOTE: split 구현에 버그가 있어서, 수동으로 구분해야함
if (isDropdownChildren(e)) {
return;
}
void pushNationCommand(-1);
}
async function pushNationCommand(amount: number) {
try {
await SammoAPI.NationCommand.PushCommand({ amount });
} catch (e) {
console.error(e);
alert(`실패했습니다: ${e}`);
return;
}
emit("raise-reload");
}
const queryActionHelper = new QueryActionHelper(props.maxTurn);
const reservedCommandList = queryActionHelper.reservedCommandList;
const prevSelectedTurnList = queryActionHelper.prevSelectedTurnList;
const selectedTurnList = queryActionHelper.selectedTurnList;
async function reserveCommandDirect(args: [number[], TurnObj][], reload = true): Promise<boolean> {
const query: {
turnList: number[];
action: string;
arg: Args;
}[] = [];
for (const [turnList, { action, arg }] of args) {
query.push({
turnList,
action,
arg,
});
}
try {
await SammoAPI.NationCommand.ReserveBulkCommand(query);
queryActionHelper.releaseSelectedTurnList();
} catch (e) {
console.error(e);
alert(`실패했습니다: ${e}`);
return false;
}
if (reload) {
emit("raise-reload");
}
return true;
}
function updateCommandList() {
if (updated.value) {
return;
}
console.log("do update!");
const _reservedCommandList: TurnObjWithTime[] = [];
let yearMonth = joinYearMonth(props.year, props.month);
const turnTime = parseTime(props.turnTime);
let nextTurnTime = new Date(turnTime);
const autorunLimitYearMonth = autorun_limit.value ?? yearMonth - 1;
const [autorunLimitYear, autorunLimitMonth] = parseYearMonth(autorunLimitYearMonth);
for (const obj of props.turn) {
const [year, month] = parseYearMonth(yearMonth);
let tooltip: string[] = [];
let style: Record<string, unknown> = {};
const brief = obj.brief;
if (yearMonth <= autorunLimitYearMonth) {
if (obj.brief == "휴식") {
obj.brief = "휴식<small>(자율 행동)</small>";
}
style.color = "#aaffff";
tooltip.push(`자율 행동 기간: ${autorunLimitYear}${autorunLimitMonth}월까지`);
}
if (mb_strwidth(brief) > 22) {
tooltip.push(brief);
}
_reservedCommandList.push({
...obj,
year,
month,
time: formatTime(nextTurnTime, props.turnTerm >= 5 ? "HH:mm" : "mm:ss"),
tooltip: tooltip.length == 0 ? undefined : tooltip.join("\n"),
style,
});
yearMonth += 1;
nextTurnTime = addMinutes(nextTurnTime, props.turnTerm);
}
reservedCommandList.value = _reservedCommandList;
updated.value = true;
}
async function reserveCommand() {
const reqTurnList = queryActionHelper.getSelectedTurnList();
const commandName = selectedCommand.value.value;
if (listReqArgCommand.has(commandName)) {
document.location.href = stringifyUrl({
url: "v_processing.php",
query: {
command: commandName,
turnList: reqTurnList.join("_"),
is_chief: true,
},
});
return;
}
try {
const result = await SammoAPI.NationCommand.ReserveCommand({
turnList: reqTurnList,
action: commandName,
});
storedActionsHelper.pushRecentActions({
action: commandName,
brief: result.brief,
arg: {},
});
queryActionHelper.releaseSelectedTurnList();
} catch (e) {
console.error(e);
alert(`실패했습니다: ${e}`);
return;
}
emit("raise-reload");
}
function chooseCommand(val?: string) {
if (val === undefined) {
return;
}
selectedCommand.value = invCommandMap[val];
void reserveCommand();
}
const emptyTurnObj: TurnObj = { action: "휴식", brief: "휴식", arg: {} };
const storedActionsHelper = inject("storedNationActionsHelper") as StoredActionsHelper;
const recentActions = storedActionsHelper.recentActions;
const storedActions = storedActionsHelper.storedActions;
const activatedCategory = storedActionsHelper.activatedCategory;
async function eraseSelectedTurnList(releaseSelect = true): Promise<boolean> {
const result = await reserveCommandDirect([[queryActionHelper.getSelectedTurnList(), emptyTurnObj]]);
if (releaseSelect) {
queryActionHelper.releaseSelectedTurnList();
}
return result;
}
const clipboard = storedActionsHelper.clipboard;
async function clipboardCut(releaseSelect = true) {
clipboardCopy(false);
return eraseSelectedTurnList(releaseSelect);
}
function clipboardCopy(releaseSelect = true) {
clipboard.value = queryActionHelper.extractQueryActions();
if (releaseSelect) {
queryActionHelper.releaseSelectedTurnList();
}
}
async function clipboardPaste(releaseSelect = true) {
const rawActions = clipboard.value;
if (rawActions === undefined) {
return;
}
const actions = queryActionHelper.amplifyQueryActions(rawActions, queryActionHelper.getSelectedTurnList());
if (actions.length === 0) {
return;
}
const result = await reserveCommandDirect(actions);
if (releaseSelect) {
queryActionHelper.releaseSelectedTurnList();
}
return result;
}
async function subRepeatCommand(releaseSelect = true): Promise<boolean> {
const reqTurnList = queryActionHelper.getSelectedTurnList();
const selectedMinTurnIdx = reqTurnList[0];
const selectedMaxTurnIdx = reqTurnList[reqTurnList.length - 1];
const queryLength = selectedMaxTurnIdx - selectedMinTurnIdx + 1;
const rawActions = queryActionHelper.extractQueryActions();
const actions = queryActionHelper.amplifyQueryActions(
rawActions,
range(selectedMinTurnIdx, props.maxTurn, queryLength)
);
const result = await reserveCommandDirect(actions);
if (releaseSelect) {
queryActionHelper.releaseSelectedTurnList();
}
return result;
}
async function eraseAndPullCommand(releaseSelect = true): Promise<boolean> {
const reqTurnList = queryActionHelper.getSelectedTurnList();
const selectedMinTurnIdx = reqTurnList[0];
const selectedMaxTurnIdx = reqTurnList[reqTurnList.length - 1];
const queryLength = selectedMaxTurnIdx - selectedMinTurnIdx + 1;
if (selectedMinTurnIdx === 0) {
await pushNationCommand(-queryLength);
return true;
}
if (selectedMinTurnIdx + queryLength == props.maxTurn) {
return eraseSelectedTurnList(releaseSelect);
}
const actions: [number[], TurnObj][] = [];
const emptyTurnList: number[] = [];
for (const srcTurnIdx of range(selectedMinTurnIdx + queryLength, props.maxTurn)) {
const rawAction = reservedCommandList.value[srcTurnIdx];
if (rawAction.action == emptyTurnObj.action) {
emptyTurnList.push(srcTurnIdx - queryLength);
continue;
}
actions.push([
[srcTurnIdx - queryLength],
{
action: rawAction.action,
arg: rawAction.arg,
brief: rawAction.brief,
},
]);
}
emptyTurnList.push(...range(props.maxTurn - queryLength, props.maxTurn));
actions.push([emptyTurnList, emptyTurnObj]);
const result = await reserveCommandDirect(actions);
if (releaseSelect) {
queryActionHelper.releaseSelectedTurnList();
}
return result;
}
async function pushEmptyCommand(releaseSelect = true): Promise<boolean> {
const reqTurnList = queryActionHelper.getSelectedTurnList();
const selectedMinTurnIdx = reqTurnList[0];
const selectedMaxTurnIdx = reqTurnList[reqTurnList.length - 1];
const queryLength = selectedMaxTurnIdx - selectedMinTurnIdx + 1;
if (selectedMinTurnIdx === 0) {
await pushNationCommand(queryLength);
return true;
}
if (selectedMaxTurnIdx == props.maxTurn) {
return eraseSelectedTurnList(releaseSelect);
}
const actions: [number[], TurnObj][] = [];
const emptyTurnList: number[] = [];
for (const srcTurnIdx of range(selectedMinTurnIdx, props.maxTurn - queryLength)) {
const rawAction = reservedCommandList.value[srcTurnIdx];
if (rawAction.action == emptyTurnObj.action) {
emptyTurnList.push(srcTurnIdx + queryLength);
continue;
}
actions.push([
[srcTurnIdx + queryLength],
{
action: rawAction.action,
arg: rawAction.arg,
brief: rawAction.brief,
},
]);
}
emptyTurnList.push(...range(selectedMinTurnIdx, selectedMinTurnIdx + queryLength));
actions.push([emptyTurnList, emptyTurnObj]);
const result = await reserveCommandDirect(actions);
if (releaseSelect) {
queryActionHelper.releaseSelectedTurnList();
}
return result;
}
function setStoredActions() {
const actions = queryActionHelper.extractQueryActions();
const turnBrief = new Map<number, string>();
for (const [subTurnList, action] of actions) {
const actionName = action.action.split("_");
const actionShortName = actionName.length == 1 ? actionName[0] : actionName[1];
for (const turnIdx of subTurnList) {
turnBrief.set(turnIdx, actionShortName[0]);
}
}
const turnBriefStr = Array.from(turnBrief.entries())
.sort(([turnA], [turnB]) => turnA - turnB)
.map(([, action]) => action)
.join("");
const nickName = trim(prompt("선택한 턴들의 별명을 지어주세요", turnBriefStr) ?? "");
if (nickName == "") {
return;
}
storedActionsHelper.setStoredActions(nickName, actions);
queryActionHelper.releaseSelectedTurnList();
}
function deleteStoredActions(actionKey: string) {
storedActionsHelper.deleteStoredActions(actionKey);
}
async function useStoredAction(rawActions: [number[], TurnObj][]) {
const reqTurnList = queryActionHelper.getSelectedTurnList();
const actions = queryActionHelper.amplifyQueryActions(rawActions, reqTurnList);
const result = await reserveCommandDirect(actions);
queryActionHelper.releaseSelectedTurnList();
return result;
}
function getQueryActionHelper(): QueryActionHelper {
return queryActionHelper;
}
function getStoredActionHeler(): StoredActionsHelper {
return storedActionsHelper;
}
defineExpose({
useStoredAction,
deleteStoredActions,
clipboardCut,
clipboardCopy,
clipboardPaste,
getQueryActionHelper,
getStoredActionHeler,
});
watch(
() => props.date,
() => {
triggerUpdateCommandList("date");
}
);
watch(
() => props.year,
() => {
triggerUpdateCommandList("year");
}
);
watch(
() => props.month,
() => {
triggerUpdateCommandList("month");
}
);
watch(
() => props.turnTime,
() => {
triggerUpdateCommandList("turnTime");
}
);
watch(
() => props.commandList,
() => {
triggerUpdateCommandList("commandList");
}
);
watch(
() => props.selectedTurn,
(val: Set<number>) => {
console.log(val);
if (val === selectedTurnList.value) {
console.log("pass!");
return;
}
selectedTurnList.value.clear();
for (const t of val.values()) {
selectedTurnList.value.add(t);
}
}
);
watch(selectedTurnList, () => {
console.log(selectedTurnList.value);
emit("update:selectedTurn", selectedTurnList.value);
});
const commandQuickReserveForm = ref<InstanceType<typeof CommandSelectForm> | null>(null);
const commandSelectForm = ref<InstanceType<typeof CommandSelectForm> | null>(null);
const currentQuickReserveTarget = ref(-1);
function chooseQuickReserveCommand(val?: string) {
if (!val) {
return;
}
selectedCommand.value = invCommandMap[val];
selectedTurnList.value.clear();
selectedTurnList.value.add(currentQuickReserveTarget.value);
void reserveCommand();
}
function toggleQuickReserveForm(turnIdx: number) {
if (turnIdx == currentQuickReserveTarget.value) {
commandQuickReserveForm.value?.toggle();
return;
}
currentQuickReserveTarget.value = turnIdx;
commandQuickReserveForm.value?.show();
}
const isEditMode = storedActionsHelper.isEditMode;
watch(isEditMode, (newEditMode) => {
if (newEditMode) {
commandQuickReserveForm.value?.close();
currentQuickReserveTarget.value = -1;
} else {
commandSelectForm.value?.close();
}
});
function toggleForm($event: Event): void {
$event.preventDefault();
const form = commandSelectForm.value;
if (!form) {
return;
}
form.toggle();
}
onMounted(() => {
updateCommandList();
});
</script>
<style lang="scss">
@import "@scss/common/break_500px.scss";
@import "@scss/common/variables.scss";
@import "@scss/common/bootswatch_custom_variables.scss";
.chiefReservedCommand {
background-color: $gray-900;
.commandTable.editMode {
width: 100%;
display: grid;
grid-template-columns: minmax(39.67px, 1fr) minmax(28px, 1fr) 5fr;
//30, 70, 37.65, 160
}
.commandTable.singleMode {
width: 100%;
display: grid;
grid-template-columns: minmax(39.67px, 1fr) 5fr minmax(28px, 1fr);
//30, 70, 160, 37.65
}
@include media-1000px {
.turn_pad {
overflow: hidden;
text-overflow: ellipsis;
}
.multiselect__content-wrapper {
margin-left: calc(-100% / 7 * 2);
width: calc(100% / 7 * 12);
}
.multiselect__single {
display: inline-block;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
}
@include media-500px {
.dropdown-item {
padding: 8px;
}
.multiselect__content-wrapper {
margin-left: calc(-100% / 7 * 2);
width: calc(100% / 7 * 12);
}
.commandPad {
margin-top: 10px;
margin-bottom: 10px;
.btn {
transition: none !important;
}
}
.month_pad,
.time_pad,
.turn_pad {
padding: 6px;
}
}
.month_pad:hover {
text-decoration: underline;
cursor: pointer;
}
.month_pad,
.time_pad,
.turn_pad {
display: flex;
justify-content: center;
align-items: center;
}
.turn_pad {
white-space: nowrap;
}
.turn_pad .turn_text {
display: inline-block;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
}
</style>
+218
View File
@@ -0,0 +1,218 @@
<template>
<div v-if="showForm" class="my-1">
<div class="commandCategory row gx-0 gy-1">
<div
v-for="[categoryKey, { deco: categoryDeco }] of commandList"
:key="categoryKey"
class="categoryItem col-4 d-grid"
>
<BButton variant="success" :active="chosenCategory == categoryKey" @click="chosenCategory = categoryKey">
{{ categoryDeco.altName ?? categoryDeco.name }}
</BButton>
</div>
</div>
<div
class="commandList my-1"
:style="{
display: 'grid',
alignItems: 'self-start',
}"
>
<div
v-for="[category, { values }] of commandList"
:key="category"
class="row gx-1 gy-1"
:style="{ visibility: category == chosenCategory ? 'visible' : 'hidden', gridRow: '1', gridColumn: '1' }"
>
<div
v-for="commandItem of values"
:key="commandItem.value"
class="col-6 d-grid"
@click="close(commandItem.value)"
>
<div class="commandItem">
<div class="commandBody">
<p :class="['center', 'my-0', commandItem.possible ? '' : 'commandImpossible']">
{{ commandItem.simpleName }}
<span v-if="commandItem.compensation > 0" class="compensatePositive"></span>
<span v-else-if="commandItem.compensation < 0" class="compensateNegative"></span>
</p>
<small class="center" :style="{ display: 'block' }">
{{
commandItem.title.startsWith(commandItem.simpleName)
? commandItem.title.substring(commandItem.simpleName.length)
: commandItem.title
}}
</small>
</div>
</div>
</div>
</div>
</div>
<div v-if="!hideClose" class="commandBottom row mt-1 mb-1">
<div class="offset-8 col-4 d-grid">
<BButton @click="close()"> 닫기 </BButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { CommandItem } from "@/defs";
import { BButton } from "bootstrap-vue-3";
import { ref, type PropType, watch, onMounted } from "vue";
interface CategoryDecoration {
name: string;
altName?: string;
//icon?: string,
//color?: string,
//backgroundColor?: string,
}
const props = defineProps({
categoryInfo: {
type: Object as PropType<Record<string, Omit<CategoryDecoration, "name">>>,
default: () => {
return {};
},
required: false,
},
commandList: {
type: Object as PropType<
{
category: string;
values: CommandItem[];
}[]
>,
required: true,
},
anchor: {
type: String,
required: false,
default: ".commandSelectFormAnchor",
},
hideClose: {
type: Boolean,
required: false,
default: true,
},
activatedCategory: {
type: String,
required: false,
default: "",
},
});
const chosenCategory = ref<string>("-");
const chosenSubList = ref<CommandItem[]>([]);
const categories = new Set(props.commandList.map(({ category }) => category));
watch(
() => props.activatedCategory,
(newValue) => {
chosenCategory.value = newValue;
}
);
const showForm = ref(false);
function convCategoryDeco(category: string): CategoryDecoration {
const itemInfo = props.categoryInfo?.[category];
if (!itemInfo) {
return {
name: category,
};
}
return {
name: category,
...itemInfo,
};
}
const commandList = ref(
new Map<
string,
{
deco: CategoryDecoration;
values: CommandItem[];
}
>()
);
function updateCommandList(rawCommandList: typeof props.commandList) {
commandList.value.clear();
for (const { category, values } of rawCommandList) {
commandList.value.set(category, {
deco: convCategoryDeco(category),
values,
});
}
}
watch(() => props.commandList, updateCommandList);
updateCommandList(props.commandList);
watch(chosenCategory, (category) => {
const itemInfo = commandList.value?.get(category);
if (itemInfo === undefined) {
console.error(`category 없음: ${category}`);
return;
}
chosenSubList.value = itemInfo.values;
if (props.activatedCategory !== category) {
emits("update:activatedCategory", category);
}
});
onMounted(() => {
if (!categories.has(props.activatedCategory)) {
chosenCategory.value = props.commandList[0].category;
} else {
chosenCategory.value = props.activatedCategory;
}
});
function show(): void {
showForm.value = true;
}
function toggle(): void {
showForm.value = !showForm.value;
if (showForm.value === false) {
emits("onClose");
}
}
function close(category?: string): void {
showForm.value = false;
emits("onClose", category);
}
const emits = defineEmits<{
(event: "onClose", command?: string): void;
(event: "update:activatedCategory", category: string): void;
}>();
defineExpose({
show,
close,
toggle,
});
</script>
<style scoped>
.commandItem {
border: gray 1px solid;
border-radius: 0.5em;
overflow: hidden;
cursor: pointer;
padding: 0.1em;
margin: 0;
min-height: 2.8em;
display: flex;
align-items: center;
justify-content: center;
}
</style>
+221
View File
@@ -0,0 +1,221 @@
<template>
<div
ref="container"
:style="{
position: 'relative',
userSelect: disabled ? undefined : 'none',
overflow: 'hidden',
touchAction: disabled ? undefined : 'none',
}"
:class="{ disabledDrag: disabled }"
>
<slot :selected="intersected" />
</div>
</template>
<script lang="ts">
/// https://github.com/andi23rosca/drag-select-vue/blob/master/src/DragSelect.vue
import { defineComponent, ref, watch, onMounted, onBeforeUnmount, type PropType } from "vue";
import VueTypes from "vue-types";
function getDimensions(p1: coord, p2: coord): rect {
return {
width: Math.abs(p1.x - p2.x),
height: Math.abs(p1.y - p2.y),
};
}
function collisionCheck(node1: DOMRect, node2: DOMRect): boolean {
return (
node1.left < node2.left + node2.width &&
node1.left + node1.width > node2.left &&
node1.top < node2.top + node2.height &&
node1.top + node1.height > node2.top
);
}
type coord = { x: number; y: number };
type rect = { width: number; height: number };
export default defineComponent({
props: {
attribute: VueTypes.string.isRequired,
color: VueTypes.string.def("#4299E1"),
opacity: VueTypes.number.def(0.7),
modelValue: {
type: Object as PropType<Set<string>>,
required: false,
default: () => ref(new Set()),
},
disabled: {
type: Boolean,
required: false,
default: false,
},
},
emits: ["update:modelValue", "dragDone", "dragStart"],
setup(props, { emit }) {
const intersected = ref<Set<string>>(props.modelValue);
const container = ref<HTMLElement>();
watch(intersected, (val) => {
emit("update:modelValue", val);
});
watch(props.modelValue, (val) => {
if (intersected.value === val) {
return;
}
intersected.value = val;
});
onMounted(() => {
if (!container.value) {
console.error(`Container is not referenced.`);
return;
}
const uContainer = container.value;
let containerRect = uContainer.getBoundingClientRect();
function getCoords(e: MouseEvent | Touch): coord {
return {
x: e.clientX - containerRect.left,
y: e.clientY - containerRect.top,
};
}
let children: HTMLCollection;
let box = document.createElement("div");
box.setAttribute("data-drag-box-component", "");
box.style.position = "absolute";
box.style.backgroundColor = props.color;
box.style.opacity = `${props.opacity}`;
let start = { x: 0, y: 0 };
let end = { x: 0, y: 0 };
function intersection() {
const rect = box.getBoundingClientRect();
const localIntersected = new Set<string>();
for (let i = 0; i < children.length; i++) {
if (collisionCheck(rect, children[i].getBoundingClientRect())) {
const attr = children[i].getAttribute(props.attribute);
if (children[i].hasAttribute(props.attribute)) {
localIntersected.add(attr as string);
}
}
}
let dismatch = false;
for (const oldVal of intersected.value) {
if (!localIntersected.has(oldVal)) {
dismatch = true;
break;
}
}
if (!dismatch) {
for (const newVal of localIntersected) {
if (!intersected.value.has(newVal)) {
dismatch = true;
break;
}
}
}
if (dismatch) {
intersected.value = localIntersected;
}
}
function touchStart(e: TouchEvent) {
e.preventDefault();
startDrag(e.touches[0]);
}
function touchMove(e: TouchEvent) {
e.preventDefault();
drag(e.touches[0]);
}
let isMine = false;
function startDrag(e: MouseEvent | Touch) {
if (props.disabled) {
return;
}
containerRect = uContainer.getBoundingClientRect();
children = uContainer.children;
start = getCoords(e);
end = start;
document.addEventListener("mousemove", drag);
document.addEventListener("touchmove", touchMove);
box.style.top = start.y + "px";
box.style.left = start.x + "px";
uContainer.append(box);
intersection();
isMine = true;
emit("dragStart");
}
function drag(e: MouseEvent | Touch) {
if (props.disabled) {
return;
}
end = getCoords(e);
const dimensions = getDimensions(start, end);
if (end.x < start.x) {
box.style.left = end.x + "px";
}
if (end.y < start.y) {
box.style.top = end.y + "px";
}
box.style.width = dimensions.width + "px";
box.style.height = dimensions.height + "px";
intersection();
}
function endDrag() {
if (props.disabled) {
return;
}
start = { x: 0, y: 0 };
end = { x: 0, y: 0 };
box.style.width = "0";
box.style.height = "0";
document.removeEventListener("mousemove", drag);
document.removeEventListener("touchmove", touchMove);
box.remove();
if (isMine) {
emit("dragDone", intersected.value);
}
isMine = false;
}
watch(
() => props.disabled,
(disabledNext) => {
if (disabledNext) {
uContainer.removeEventListener("mousedown", startDrag);
uContainer.removeEventListener("touchstart", touchStart);
document.removeEventListener("mouseup", endDrag);
document.removeEventListener("touchend", endDrag);
} else {
uContainer.addEventListener("mousedown", startDrag);
uContainer.addEventListener("touchstart", touchStart);
document.addEventListener("mouseup", endDrag);
document.addEventListener("touchend", endDrag);
}
}
);
if (!props.disabled) {
uContainer.addEventListener("mousedown", startDrag);
uContainer.addEventListener("touchstart", touchStart);
document.addEventListener("mouseup", endDrag);
document.addEventListener("touchend", endDrag);
}
onBeforeUnmount(() => {
uContainer.removeEventListener("mousedown", startDrag);
uContainer.removeEventListener("touchstart", touchStart);
document.removeEventListener("mouseup", endDrag);
document.removeEventListener("touchend", endDrag);
});
});
return {
intersected,
container,
};
},
});
</script>
+266
View File
@@ -0,0 +1,266 @@
<template>
<div class="general-card-basic">
<div
class="general-icon"
:style="{
backgroundImage: `url(${iconPath})`,
}"
></div>
<div
class="general-name"
:style="{
color: isBrightColor(nation.color) ? '#000' : '#fff',
backgroundColor: nation.color,
}"
>
{{ general.name }} {{ general.officerLevelText }} | {{ generalTypeCall }} |
<span :style="{ color: injuryInfo.color }">{{ injuryInfo.text }}</span>
{{ general.turntime.substring(11, 19) }}
</div>
<div>통솔</div>
<div>
<div class="row gx-0">
<div class="col">
<span :style="{ color: injuryInfo.color }">{{ general.leadership }}</span>
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-if="general.lbonus > 0" style="color: cyan">+{{ general.lbonus }}</span>
</div>
<div class="col">
<SammoBar :height="10" :percent="general.leadership_exp / 20" />
</div>
</div>
</div>
<div>무력</div>
<div>
<div class="row gx-0">
<div class="col" :style="{ color: injuryInfo.color }">
{{ general.strength }}
</div>
<div class="col">
<SammoBar :height="10" :percent="general.strength_exp / 20" />
</div>
</div>
</div>
<div>지력</div>
<div>
<div class="row gx-0">
<div class="col" :style="{ color: injuryInfo.color }">
{{ general.intel }}
</div>
<div class="col">
<SammoBar :height="10" :percent="general.intel_exp / 20" />
</div>
</div>
</div>
<div>명마</div>
<div v-b-tooltip.hover :title="horse.info ?? undefined">{{ horse.name }}</div>
<div>무기</div>
<div v-b-tooltip.hover :title="weapon.info ?? undefined">{{ weapon.name }}</div>
<div>서적</div>
<div v-b-tooltip.hover :title="book.info ?? undefined">{{ book.name }}</div>
<div>자금</div>
<div>{{ general.gold.toLocaleString() }}</div>
<div>군량</div>
<div>{{ general.rice.toLocaleString() }}</div>
<div>도구</div>
<div v-b-tooltip.hover :title="item.info ?? undefined">{{ item.name }}</div>
<!-- TODO: show_img_level을 고려 -->
<div
class="general-crew-type-icon"
:style="{
backgroundImage: `url(${imagePath}/crewtype${general.crewtype}.png)`,
}"
></div>
<div>병종</div>
<div v-b-tooltip.hover :title="crewtype.info ?? undefined">{{ crewtype.name }}</div>
<div>병사</div>
<div>{{ general.crew.toLocaleString() }}</div>
<div>성격</div>
<div v-b-tooltip.hover :title="personal.info ?? undefined">{{ personal.name }}</div>
<!-- TODO: bonusTrain 같은 개념이 필요 -->
<div>훈련</div>
<div>{{ general.train }}</div>
<div>사기</div>
<div>{{ general.atmos }}</div>
<div>특기</div>
<div>
<span v-b-tooltip.hover :title="specialDomestic.info ?? undefined"> {{ specialDomestic.name }}</span> /
<span v-b-tooltip.hover :title="specialWar.info ?? undefined"> {{ specialWar.name }}</span>
</div>
<div>Lv</div>
<!-- TODO: 경험치 막대가 필요 -->
<div class="general-exp-level">
{{ general.explevel }}
</div>
<div class="general-exp-level-bar">{{ nextExpLevelRemain(general.experience, general.explevel) }}</div>
<div>연령</div>
<div :style="{ color: ageColor }">{{ general.age }}</div>
<div>수비</div>
<div class="general-defence-train">
<span v-if="general.defence_train === 999" style="color: red">수비 안함</span>
<span v-else style="color: limegreen">수비 (훈사{{ general.defence_train }})</span>
</div>
<div>삭턴</div>
<div>{{ general.killturn }} </div>
<div>실행</div>
<div>{{ nextExecuteMinute }} 남음</div>
<div>부대</div>
<div v-if="!troopInfo" class="general-troop">-</div>
<div v-else class="general-troop">
<s v-if="troopInfo.leader.reservedCommand[0]?.action != 'che_집합'" style="color: gray">
{{ troopInfo.name }}
</s>
<span v-else style="color: orange">
{{ troopInfo.name }}({{ gameConstStore.cityConst[troopInfo.leader.city].name }})
</span>
</div>
<div>벌점</div>
<div class="general-v">
{{ formatConnectScore(general.connect) }} {{ general.connect.toLocaleString() }}({{ general.con }})
</div>
</div>
</template>
<script lang="ts" setup>
import type { GeneralListItemP1 } from "@/defs/API/Nation";
import { computed, inject, onMounted, ref, toRefs, type Ref } from "vue";
import { getIconPath } from "@/util/getIconPath";
import { isBrightColor } from "@/util/isBrightColor";
import { formatInjury } from "@/utilGame/formatInjury";
import type { NationStaticItem } from "@/defs";
import { unwrap } from "@/util/unwrap";
import type { GameConstStore } from "@/GameConstStore";
import { formatGeneralTypeCall } from "@/utilGame/formatGeneralTypeCall";
import { nextExpLevelRemain } from "@/utilGame/nextExpLevelRemain";
import { formatConnectScore } from "@/utilGame/formatConnectScore";
import SammoBar from "@/components/SammoBar.vue";
import { parseTime } from "@/util/parseTime";
import { clamp } from "lodash";
const imagePath = window.pathConfig.gameImage;
const gameConstStore = unwrap(inject<Ref<GameConstStore>>("gameConstStore"));
const props = defineProps<{
general: GeneralListItemP1;
troopInfo?: {
leader: GeneralListItemP1;
name: string;
};
nation: NationStaticItem;
}>();
const { general, troopInfo, nation } = toRefs(props);
const iconPath = computed(() => getIconPath(general.value.imgsvr, general.value.picture));
const injuryInfo = computed(() => {
const [text, color] = formatInjury(general.value.injury);
return {
text,
color,
};
});
const generalTypeCall = computed(() =>
formatGeneralTypeCall(
general.value.leadership,
general.value.strength,
general.value.intel,
gameConstStore.value.gameConst
)
);
const horse = computed(
() => gameConstStore.value.iActionInfo.item[general.value.horse] ?? { value: "None", name: "-" }
);
const weapon = computed(
() => gameConstStore.value.iActionInfo.item[general.value.weapon] ?? { value: "None", name: "-" }
);
const book = computed(() => gameConstStore.value.iActionInfo.item[general.value.book] ?? { value: "None", name: "-" });
const item = computed(() => gameConstStore.value.iActionInfo.item[general.value.item] ?? { value: "None", name: "-" });
const crewtype = computed(
() => gameConstStore.value.iActionInfo.crewtype[general.value.crewtype] ?? { value: "None", name: "-" }
);
const personal = computed(
() => gameConstStore.value.iActionInfo.personality[general.value.personal] ?? { value: "None", name: "-" }
);
const specialDomestic = computed(
() =>
gameConstStore.value.iActionInfo.specialDomestic[general.value.specialDomestic] ?? {
value: "None",
name: `${general.value.specage}`,
}
);
const specialWar = computed(
() =>
gameConstStore.value.iActionInfo.specialWar[general.value.specialWar] ?? {
value: "None",
name: `${general.value.specage2}`,
}
);
const ageColor = computed(() => {
const age = general.value.age;
const retirementYear = gameConstStore.value.gameConst.retirementYear;
if (age < retirementYear * 0.75) {
return "limegreen";
}
if (age < retirementYear) {
return "yellow";
}
return "red";
});
const nextExecuteMinute = ref(999);
onMounted(() => {
const now = new Date();
const turnTime = parseTime(general.value.turntime);
nextExecuteMinute.value = Math.floor(clamp(turnTime.getSeconds() - now.getSeconds() / 60, 0));
});
</script>
<style lang="scss" scoped>
.general-card-basic {
display: grid;
grid-template-columns: 64px repeat(3, 2fr 5fr);
grid-template-rows: repeat(9, calc(64px / 3));
text-align: center;
font-size: 14px;
}
.general-icon {
width: 64px;
height: 64px;
background-size: contain;
background-repeat: no-repeat;
grid-row: 1 / 4;
}
.general-name {
grid-row: 1 / 2;
grid-column: 2 / 8;
font-weight: bold;
}
.general-crew-type-icon {
width: 64px;
height: 64px;
background-size: contain;
background-repeat: no-repeat;
grid-row: 4 / 7;
}
.general-exp-level-bar {
grid-column: 3 / 6;
}
.general-defence-train {
grid-column: 2 / 4;
}
</style>
+1243
View File
@@ -0,0 +1,1243 @@
<template>
<Teleport v-if="toolbarID" :to="`#${toolbarID}`">
<BButtonGroup class="d-flex general-list-toolbar">
<BDropdown class="w-50" menuClass="view-mode-list" variant="primary" text="보기 모드">
<BDropdownItem @click="setDisplaySetting([true, 'normal'], defaultDisplaySetting.normal)">기본</BDropdownItem>
<BDropdownItem @click="setDisplaySetting([true, 'war'], defaultDisplaySetting.war)">전투</BDropdownItem>
<BDropdownDivider />
<BDropdownItem @click="storeDisplaySetting"><i class="bi bi-bookmark-plus-fill" />&nbsp;보관하기</BDropdownItem>
<BDropdownDivider />
<BDropdownItem
v-for="[key, setting] of displaySettings.entries()"
:key="key"
@click="setDisplaySetting([false, key], setting)"
><div class="row gx-0">
<div class="col-9 text-wrap">
<span class="align-middle">{{ key }}</span>
</div>
<div class="col-3">
<div class="d-grid"><BButton size="sm" @click="deleteDisplaySetting(key)">삭제</BButton></div>
</div>
</div></BDropdownItem
>
</BDropdown>
<!-- eslint-disable-next-line vue/max-attributes-per-line -->
<BDropdown class="w-50" variant="info" text="열 선택" menuClass="column-menu" right>
<template v-for="[colID, col, depth] of getColumnList()" :key="[colID, depth]">
<BDropdownItem v-if="col instanceof ProvidedColumnGroup" disabled>
<span :style="{ marginLeft: depth ? `${12 * depth}px` : undefined }">
{{ col.getColGroupDef()?.headerName }}</span
></BDropdownItem
>
<BDropdownItem v-else>
<div :style="{ marginLeft: depth ? `${12 * depth}px` : undefined }" class="form-check" @click.stop="1">
<input
:id="`column-type-${colID}`"
class="form-check-input"
type="checkbox"
:checked="col.isVisible()"
@change.stop="toggleColumn(colID, col)"
/>
<label
class="form-check-label"
:for="`column-type-${colID}`"
:style="{
textDecoration: validColumns.has(colID) ? undefined : 'line-through',
}"
>
{{ col.getColDef().headerName }}
</label>
</div></BDropdownItem
>
</template>
<BDropdownDivider />
</BDropdown>
</BButtonGroup>
</Teleport>
<div
class="component-general-list"
:style="{
height: props.height === 'fill' ? '100%' : props.height === 'static' ? undefined : `${props.height}px`,
}"
>
<AgGridVue
style="width: 100%; height: 100%"
class="ag-theme-balham-dark"
:getRowId="getRowId"
:getRowHeight="getRowHeight"
:columnDefs="columnDefs"
:rowData="list"
:defaultColDef="defaultColDef"
:suppressColumnMoveAnimation="suppressColumnMoveAnimation"
@grid-ready="onGridReady"
@cell-clicked="onCellClicked"
/>
</div>
</template>
<script lang="ts"></script>
<script lang="ts" setup>
import type { GeneralListItem, GeneralListItemP1, GeneralListItemP2, GeneralListResponse } from "@/defs/API/Nation";
import { getIconPath } from "@/util/getIconPath";
import { inject, ref, watch, type PropType, type Ref, type StyleValue } from "vue";
import { AgGridVue } from "ag-grid-vue3";
import type {
Column,
CellClassParams,
CellStyle,
ColDef,
ColGroupDef,
ColumnApi,
GetRowIdParams,
GridApi,
GridReadyEvent,
RowNode,
CellClickedEvent,
} from "ag-grid-community";
import { ProvidedColumnGroup } from "ag-grid-community";
import { getNpcColor } from "@/common_legacy";
import type { BaseWithValueColDefParams, ValueGetterParams } from "ag-grid-community/dist/lib/entities/colDef";
import type { GameConstStore } from "@/GameConstStore";
import { unwrap } from "@/util/unwrap";
import SimpleTooltipCell from "@/gridCellRenderer/SimpleTooltipCell.vue";
import GridTooltipCell, { type GridCellInfo } from "@/gridCellRenderer/GridTooltipCell.vue";
import { formatConnectScore } from "@/utilGame/formatConnectScore";
import { convertSearch초성 } from "@/util/convertSearch초성";
import { isString } from "lodash";
import { formatDefenceTrain } from "@/utilGame/formatDefenceTrain";
import { BDropdownItem, BDropdownDivider, BButtonGroup, BDropdown, BButton } from "bootstrap-vue-3";
import { unwrap_err } from "@/util/unwrap_err";
import { RuntimeError } from "@/util/RuntimeError";
import { defaultDisplaySetting, type GridDisplaySetting } from "@/defs/gridDefs";
const props = defineProps({
list: {
type: Array as PropType<GeneralListItem[]>,
required: true,
},
troops: {
type: Object as PropType<Record<number, string>>,
required: true,
},
height: {
type: String as PropType<"static" | "fill" | number | `${number}px` | `${number}%`>,
required: false,
default: "static",
},
env: {
type: Object as PropType<GeneralListResponse["env"]>,
required: true,
},
toolbarID: {
type: String,
required: false,
default: undefined,
},
role: {
type: String,
required: false,
default: "generic",
},
availableGeneralClick: {
type: Boolean,
required: false,
default: true,
},
});
const emit = defineEmits<{
(e: "generalClick", generalID: number): void;
}>();
const suppressColumnMoveAnimation = ref(true);
const gameConstStore = unwrap(inject<Ref<GameConstStore>>("gameConstStore"));
const validColumns = ref(new Set<string>());
watch(
() => props.list,
(newValue) => {
const newValidColumns = new Set<string>(["icon"]);
if (newValue.length > 0) {
for (const key of Object.keys(newValue[0])) {
newValidColumns.add(key);
}
validColumns.value = newValidColumns;
}
setTimeout(() => {
gridApi.value?.redrawRows();
}, 0);
}
);
watch(
() => props.height,
(val) => {
if (val === "static") {
gridApi.value?.setDomLayout("autoHeight");
} else {
gridApi.value?.setDomLayout("normal");
}
}
);
const generalByID = ref(new Map<number, GeneralListItem>());
function refineGeneralList(list: GeneralListItem[]) {
const map = new Map<number, GeneralListItem>();
for (const general of list) {
map.set(general.no, general);
}
generalByID.value = map;
}
refineGeneralList(props.list);
watch(() => props.list, refineGeneralList);
const gridApi = ref<GridApi>();
const columnApi = ref<ColumnApi>();
const rowHeight = ref(68);
function getRowId(params: GetRowIdParams): string {
const genID = (params.data as GeneralListItem).no;
return `${genID}`;
}
function setDisplaySetting(settingKey: SettingKeyType, setting: GridDisplaySetting) {
if (!columnApi.value) {
console.error("nyc?");
return;
}
columnApi.value.applyColumnState({ state: setting.column, applyOrder: true });
columnApi.value.setColumnGroupState(setting.columnGroup);
currentSetting.value = settingKey;
}
const displaySettings = ref(new Map<string, GridDisplaySetting>());
const displaySettingVersion = 1; //추가되는 걸로 버전 올리지 말고, 사용할 수 없게 될때만 올리기
const displaySettingsKey = "GeneralListDisplaySetting";
function getLastUsedSettingsKey() {
const lastUsedSettingsKey = "LastUsedSettingsKey";
return `${lastUsedSettingsKey}_${props.role}`;
}
function loadDisplaySetting() {
const rawSettings = localStorage.getItem(displaySettingsKey);
if (!rawSettings) {
return;
}
const settings: { version: number; settings: [string, GridDisplaySetting][] } = JSON.parse(rawSettings);
if (settings.version != displaySettingVersion) {
localStorage.removeItem(displaySettingsKey);
return;
}
displaySettings.value = new Map(settings.settings);
}
loadDisplaySetting();
type SettingKeyType = [true, keyof typeof defaultDisplaySetting] | [false, string];
const currentSetting = ref<SettingKeyType>([true, "normal"]);
function loadLastUsedSettings() {
const rawLastSettingKey = localStorage.getItem(getLastUsedSettingsKey());
if (!rawLastSettingKey) {
return;
}
const settingKey: SettingKeyType = JSON.parse(rawLastSettingKey);
const [isDefault, settingKeyName] = settingKey;
if (isDefault) {
if (!(settingKeyName in defaultDisplaySetting)) {
console.error(`${settingKeyName}은 이제 기본 지원 타입이 아닙니다.`);
return;
}
} else {
if (!displaySettings.value.has(settingKeyName)) {
console.error(`${settingKeyName}는 저장되어있지 않습니다.`);
return;
}
}
currentSetting.value = settingKey;
return settingKey;
}
watch(currentSetting, (newTypeKey) => {
localStorage.setItem(getLastUsedSettingsKey(), JSON.stringify(newTypeKey));
});
watch(
displaySettings,
(newSettings) => {
const settings = Array.from(newSettings.entries());
localStorage.setItem(
displaySettingsKey,
JSON.stringify({
version: displaySettingVersion,
settings,
})
);
console.log("저장!", Array.from(newSettings.keys()));
},
{ deep: true }
);
function deleteDisplaySetting(key: string) {
if (!confirm(`${key} 설정을 지울까요?`)) {
return;
}
displaySettings.value.delete(key);
}
function storeDisplaySetting() {
if (!columnApi.value) {
console.error("nyc?");
return;
}
const nickName = prompt("선택한 설정의 별명을 지어주세요", currentSetting.value[0] ? "" : currentSetting.value[1]);
if (!nickName) {
return;
}
if (displaySettings.value.has(nickName)) {
if (!confirm("이미 있는 이름입니다. 덮어쓸까요?")) {
return;
}
}
const setting: GridDisplaySetting = {
column: columnApi.value.getColumnState(),
columnGroup: columnApi.value.getColumnGroupState(),
};
displaySettings.value.set(nickName, setting);
currentSetting.value = [false, nickName];
}
function onGridReady(params: GridReadyEvent) {
gridApi.value = params.api;
columnApi.value = params.columnApi;
if (props.height === "static") {
params.api.setDomLayout("autoHeight");
} else {
params.api.setDomLayout("normal");
}
loadLastUsedSettings();
if (currentSetting.value[0]) {
setDisplaySetting(currentSetting.value, defaultDisplaySetting[currentSetting.value[1]]);
} else {
setDisplaySetting(currentSetting.value, unwrap(displaySettings.value.get(currentSetting.value[1])));
}
setTimeout(() => {
suppressColumnMoveAnimation.value = false;
}, 1);
}
function onCellClicked(event: CellClickedEvent) {
const colID = event.column.getColId();
if (colID === "icon" || colID === "name") {
const generalItem = event.data as GeneralListItem;
emit("generalClick", generalItem.no);
}
}
function getRowHeight(): number {
return rowHeight.value;
}
type headerType =
| keyof Omit<GeneralListItemP2, "no" | "imgsvr" | "picture" | "lbonus" | "permission" | "st0" | "st1" | "st2">
| "stat"
| "icon"
| "goldRice"
| "expDedLv"
| "crewtypeAndCrew"
| "trainAtmos"
| "specials"
| "reservedCommandShort"
| "killturnAndConnect"
| "years"
| "warResults";
interface GenValueParams extends BaseWithValueColDefParams {
data: GeneralListItem;
}
interface GenValueGetterParams extends ValueGetterParams {
data: GeneralListItem;
}
interface GenRowNode extends RowNode {
data: GeneralListItem;
}
interface GenColDef extends ColDef {
colId: headerType;
field?: headerType;
headerName: string;
valueFormatter?: string | ((params: GenValueParams) => string);
filterValueGetter?: string | ((params: GenValueGetterParams) => unknown);
valueGetter?: string | ((params: GenValueGetterParams) => unknown);
}
interface GenColGroupDef extends ColGroupDef {
headerName: string;
children: GenColDef[]; //1단만 할꺼다!
groupId: headerType;
}
interface GenCellClassParams extends CellClassParams {
data: GeneralListItem;
}
function getColumnList(): [headerType, ProvidedColumnGroup | Column, number?][] {
const result: [headerType, ProvidedColumnGroup | Column, number?][] = [];
if (!columnApi.value) {
return result;
}
for (const [rawColKey, rawColDef] of Object.entries(columnRawDefs.value)) {
if (rawColKey === "name") {
continue;
}
if (!("children" in rawColDef)) {
const col = unwrap_err(columnApi.value.getColumn(rawColDef.colId), RuntimeError, `no col: ${rawColDef.colId}`);
result.push([rawColDef.colId, col]);
continue;
}
const colGroup = unwrap_err(
columnApi.value.getProvidedColumnGroup(rawColDef.groupId),
RuntimeError,
`no colGroup: ${rawColDef.groupId}`
);
result.push([rawColDef.groupId, colGroup]);
for (const subColDef of rawColDef.children) {
const subColId = subColDef.colId;
if (rawColDef.groupId == subColId) {
continue;
}
const col = unwrap_err(columnApi.value.getColumn(subColId), RuntimeError, `no subCol: ${subColDef.colId}`);
result.push([subColId, col, 1]);
}
}
return result;
}
function naiveCheClassNameFilter(value: string): string {
if (!value) {
return "-";
}
const text = value.split("_").pop() ?? "None";
if (text === "None") {
return "-";
}
return text;
}
function numberFormatter(unit?: string) {
if (unit) {
return (value: GenValueParams): string => {
if (value.value == null) {
return "?";
}
const valueText = (value.value as number).toLocaleString();
return `${valueText} ${unit}`;
};
}
return (value: GenValueParams): string => {
return (value.value as number).toLocaleString();
};
}
function extractTroopInfo(value: GeneralListItem): [string, GeneralListItemP1] | undefined {
if (!value.st1) {
return undefined;
}
const troopID = value.troop;
if (!(troopID in props.troops)) {
return undefined;
}
const troopName = props.troops[troopID];
const troopLeader = generalByID.value.get(troopID) as GeneralListItemP1 | undefined;
if (troopLeader === undefined) {
return undefined;
}
return [troopName, troopLeader];
}
function toggleColumn(colID: headerType, col: Column) {
const newState = !col.isVisible();
const target: string[] = [colID];
const parent = col.getParent();
if (newState) {
for (const child of (parent.getChildren() ?? []) as Column[]) {
if (parent.getGroupId() == child.getColDef().colId) {
target.push(child.getColId());
break;
}
}
} else {
let stillVisible = false;
let header: string | null = null;
for (const child of (parent.getChildren() ?? []) as Column[]) {
if (child.getColId() == colID) {
continue;
}
if (parent.getGroupId() == child.getColDef().colId) {
header = child.getColId();
continue;
}
stillVisible = true;
break;
}
if (!stillVisible && header) {
target.push(header);
}
}
columnApi.value?.setColumnsVisible(target, newState);
}
const defaultCellClass = ["cell-middle"];
const centerCellClass = [...defaultCellClass, "cell-center"];
const rightAlignClass = [...defaultCellClass, "cell-right"];
const sortableNumber: Omit<GenColDef, "colId" | "headerName"> = {
sortable: true,
comparator: (a, b) => a - b,
sortingOrder: ["desc", "asc", null],
filter: "number",
cellClass: rightAlignClass,
};
const defaultColDef = ref<ColDef>({
resizable: true,
headerClass: "default-cell-header",
cellClass: centerCellClass,
floatingFilter: true,
width: 80,
});
const columnRawDefs = ref<Partial<Record<headerType, GenColDef | GenColGroupDef>>>({
icon: {
colId: "icon",
headerName: "아이콘",
width: 64 + 16,
suppressSizeToFit: true,
resizable: false,
cellRenderer: (obj: GenValueParams) => {
const { data: gen } = obj;
return `<img src="${getIconPath(gen.imgsvr, gen.picture)}" width="64">`;
},
pinned: "left",
cellClass: [props.availableGeneralClick ? "clickable-cell" : "", ...defaultCellClass],
lockPosition: true,
},
name: {
headerName: "장수명",
colId: "name",
field: "name",
pinned: "left",
sortable: true,
width: 120,
sortingOrder: ["asc", "desc", null],
lockPosition: true,
cellStyle: (val: GenCellClassParams) => {
const gen = val.data;
const style: StyleValue = {
color: getNpcColor(gen.npc),
};
return style as CellStyle;
},
comparator: (_lhs, _rhs, { data: lhs }: GenRowNode, { data: rhs }: GenRowNode) => {
const npcDiff = lhs.npc - rhs.npc;
if (npcDiff != 0) {
return npcDiff;
}
return lhs.name.localeCompare(rhs.name);
},
filterValueGetter: ({ data }) => convertSearch초성(data.name),
cellClass: [props.availableGeneralClick ? "clickable-cell" : "", ...defaultCellClass],
filter: true,
hide: false,
lockVisible: true,
},
//npc: { headerName: "NPC", colId: "npc", field: "npc" },
stat: {
groupId: "stat",
openByDefault: false,
headerName: "능력치",
children: [
{
colId: "stat",
headerName: "통|무|지",
width: 88,
cellRenderer: (obj: GenValueParams) => {
const gen = obj.data;
return `${gen.leadership}|${gen.strength}|${gen.intel}`;
},
columnGroupShow: "closed",
},
{
colId: "leadership",
headerName: "통솔",
field: "leadership",
...sortableNumber,
columnGroupShow: "open",
width: 60,
type: "numericColumn",
},
{
colId: "strength",
headerName: "무력",
field: "strength",
...sortableNumber,
columnGroupShow: "open",
width: 60,
},
{
colId: "intel",
headerName: "지력",
field: "intel",
...sortableNumber,
columnGroupShow: "open",
width: 60,
},
],
},
officerLevel: {
headerName: "관직",
colId: "officerLevel",
field: "officerLevelText",
sortable: true,
comparator: (a, b, c, d) => c.data.officerLevel - d.data.officerLevel,
cellRenderer: ({ data }: GenValueParams) => {
if (data.officerLevel >= 5) {
return `<span style="color:cyan;">${data.officerLevelText}</span>`;
}
if (data.st1 && 2 <= data.officerLevel && data.officerLevel <= 4) {
const cityName = gameConstStore.value.cityConst[data.officer_city].name;
return `${cityName}<br>${data.officerLevelText}`;
}
return data.officerLevelText;
},
filterValueGetter: ({ data }) => {
if (data.st1 && 2 <= data.officerLevel && data.officerLevel <= 4) {
const cityName = gameConstStore.value.cityConst[data.officer_city].name;
return convertSearch초성(`${cityName} ${data.officerLevelText}`);
}
return convertSearch초성(data.officerLevelText);
},
filter: true,
cellClass: centerCellClass,
width: 70,
},
expDedLv: {
headerName: "명성/계급",
groupId: "expDedLv",
width: 70,
children: [
{
colId: "expDedLv",
headerName: "",
columnGroupShow: "closed",
width: 60,
cellRenderer: ({ data }: GenValueParams) => {
return `Lv ${data.explevel}<br>${data.dedLevelText}`;
},
},
{
colId: "explevel",
headerName: "명성",
field: "explevel",
width: 60,
cellRenderer: ({ data }: GenValueParams) => {
return `Lv ${data.explevel}<br>(${data.honorText})`;
},
...sortableNumber,
cellClass: centerCellClass,
columnGroupShow: "open",
},
{
colId: "dedlevel",
headerName: "계급",
field: "dedLevelText",
width: 70,
cellRenderer: ({ data }: GenValueParams) => {
return `${data.dedLevelText}<br>(${data.bill.toLocaleString()})`;
},
...sortableNumber,
cellClass: centerCellClass,
columnGroupShow: "open",
},
],
},
goldRice: {
headerName: "자금",
groupId: "goldRice",
children: [
{
colId: "goldRice",
headerName: "금/쌀",
cellRenderer: ({ data }: GenValueParams) => {
return `${data.gold.toLocaleString()} 금<br>${data.rice.toLocaleString()}`;
},
width: 80,
cellClass: rightAlignClass,
columnGroupShow: "closed",
sortable: true,
sortingOrder: ["desc", "asc", null],
comparator(_a, _b, { data: lhs }: GenRowNode, { data: rhs }: GenRowNode) {
const lhsAmount = lhs.gold + lhs.rice;
const rhsAmount = rhs.gold + rhs.rice;
return lhsAmount - rhsAmount;
},
},
{
colId: "gold",
headerName: "금",
field: "gold",
...sortableNumber,
valueFormatter: numberFormatter("금"),
width: 70,
columnGroupShow: "open",
},
{
colId: "rice",
headerName: "쌀",
field: "rice",
...sortableNumber,
valueFormatter: numberFormatter("쌀"),
width: 70,
columnGroupShow: "open",
},
],
},
city: {
colId: "city",
headerName: "도시",
field: "city",
valueGetter: ({ data }) => {
if (!data.st1) {
return "?";
}
return gameConstStore.value.cityConst[data.city].name;
},
filter: true,
sortable: true,
width: 60,
filterValueGetter: ({ data }) => {
if (!data.st1) {
return "";
}
return convertSearch초성(gameConstStore.value.cityConst[data.city].name);
},
},
troop: {
colId: "troop",
headerName: "부대",
field: "troop",
valueGetter: ({ data }: GenValueGetterParams) => {
if (!data.st1) {
return "?";
}
const troopInfo = extractTroopInfo(data);
if (troopInfo === undefined) {
return "-";
}
const [troopName, troopLeader] = troopInfo;
const cityName = gameConstStore.value.cityConst[troopLeader.city].name;
return [troopName, cityName];
},
cellRenderer: ({ value }: { value: [string, string] | string }) => {
if (isString(value)) {
return value;
}
const [troopName, cityName] = value;
return `${troopName}<br>[${cityName}]`;
},
width: 90,
sortable: true,
comparator: (valX, valB, { data: lhs }: GenRowNode, { data: rhs }: GenRowNode) => {
const troopInfoLhs = extractTroopInfo(lhs);
const troopInfoRhs = extractTroopInfo(rhs);
console.log(troopInfoLhs, troopInfoRhs);
if (troopInfoLhs === troopInfoRhs) {
return 0;
}
if (troopInfoLhs === undefined) {
return 1;
}
if (troopInfoRhs === undefined) {
return -1;
}
return troopInfoLhs[0].localeCompare(troopInfoRhs[0]);
},
filter: true,
filterValueGetter: ({ data }) => {
const troopInfo = extractTroopInfo(data);
if (troopInfo === undefined) {
return "-";
}
const [troopName, troopLeader] = troopInfo;
const cityName = gameConstStore.value.cityConst[troopLeader.city].name;
return convertSearch초성(`${troopName}$${cityName}`);
},
},
crewtypeAndCrew: {
groupId: "crewtypeAndCrew",
headerName: "보유 병력",
children: [
{
colId: "crewtypeAndCrew",
headerName: "병종",
cellRenderer: GridTooltipCell,
cellRendererParams: {
cells: ((): GridCellInfo[][] => {
return [
[{ target: "crewtype", iActionMap: gameConstStore.value.iActionInfo.crewtype }],
[{ target: "crew", converter: (value) => [`${value.crew.toLocaleString()}`, undefined] }],
];
})(),
},
columnGroupShow: "closed",
},
{
colId: "crewtype",
headerName: "병종",
field: "crewtype",
cellRenderer: SimpleTooltipCell,
cellRendererParams: {
iActionMap: gameConstStore.value.iActionInfo.crewtype,
},
sortable: true,
columnGroupShow: "open",
filter: true,
filterValueGetter: ({ data }) => {
if (!data.st1) {
return "?";
}
const name = gameConstStore.value.iActionInfo.crewtype[data.crewtype].name;
return convertSearch초성(name);
},
},
{
colId: "crew",
headerName: "병력",
field: "crew",
...sortableNumber,
valueFormatter: numberFormatter("명"),
width: 70,
columnGroupShow: "open",
},
],
},
trainAtmos: {
groupId: "trainAtmos",
headerName: "훈/사",
children: [
{
colId: "trainAtmos",
headerName: "훈/사",
width: 60,
cellRenderer: ({ data }: GenValueParams) => {
if (!data.st1) {
return "?";
}
return `${data.train}<br>${data.atmos}`;
},
columnGroupShow: "closed",
},
{
colId: "train",
headerName: "훈련",
field: "train",
...sortableNumber,
valueFormatter: numberFormatter(),
width: 70,
columnGroupShow: "open",
},
{
colId: "atmos",
headerName: "사기",
field: "atmos",
...sortableNumber,
valueFormatter: numberFormatter(),
width: 70,
columnGroupShow: "open",
},
{
colId: "defence_train",
headerName: "수비",
field: "defence_train",
sortable: true,
sortingOrder: ["desc", "asc", null],
valueFormatter: ({ value }) => formatDefenceTrain(value as number),
width: 50,
},
],
},
specials: {
groupId: "specials",
headerName: "특성",
children: [
{
colId: "specials",
headerName: "요약",
cellRenderer: GridTooltipCell,
cellRendererParams: {
cells: ((): GridCellInfo[][] => {
return [
[{ target: "personal", iActionMap: gameConstStore.value.iActionInfo.personality }],
[
{ target: "specialDomestic", iActionMap: gameConstStore.value.iActionInfo.specialDomestic },
{ target: "specialWar", iActionMap: gameConstStore.value.iActionInfo.specialWar },
],
];
})(),
},
width: 80,
columnGroupShow: "closed",
},
{
colId: "personal",
headerName: "성격",
field: "personal",
cellRenderer: SimpleTooltipCell,
cellRendererParams: {
iActionMap: gameConstStore.value.iActionInfo.personality,
},
width: 60,
sortable: true,
filter: true,
columnGroupShow: "open",
filterValueGetter: ({ data }) => {
const name = gameConstStore.value.iActionInfo.personality[data.personal].name;
return convertSearch초성(name);
},
},
{
colId: "specialDomestic",
headerName: "내특",
field: "specialDomestic",
cellRenderer: SimpleTooltipCell,
cellRendererParams: {
iActionMap: gameConstStore.value.iActionInfo.specialDomestic,
},
width: 60,
sortable: true,
filter: true,
columnGroupShow: "open",
filterValueGetter: ({ data }) => {
const name = gameConstStore.value.iActionInfo.specialDomestic[data.specialDomestic].name;
return convertSearch초성(name);
},
},
{
colId: "specialWar",
headerName: "전특",
field: "specialWar",
cellRenderer: SimpleTooltipCell,
cellRendererParams: {
iActionMap: gameConstStore.value.iActionInfo.specialWar,
},
width: 60,
sortable: true,
filter: true,
columnGroupShow: "open",
filterValueGetter: ({ data }) => {
const name = gameConstStore.value.iActionInfo.specialWar[data.specialWar].name;
return convertSearch초성(name);
},
},
],
},
reservedCommandShort: {
groupId: "reservedCommandShort",
headerName: "명령",
children: [
{
colId: "reservedCommandShort",
headerName: "단축",
width: 70,
cellRenderer: ({ data }: GenValueParams) => {
if (data.npc >= 2) {
return "NPC 장수";
}
if (!data.reservedCommand) {
return "-";
}
const commandList = data.reservedCommand;
if (!commandList) {
return "???";
}
return commandList
.map(({ action }) => {
if (action !== "휴식" || data.npc >= 2) {
return naiveCheClassNameFilter(action);
}
const limitMinutes = props.env.autorun_user?.limit_minutes ?? 0;
if (!limitMinutes) {
return naiveCheClassNameFilter(action);
}
if (data.killturn + limitMinutes > props.env.killturn) {
return "자율행동";
}
return naiveCheClassNameFilter(action);
})
.join("<br>");
},
cellStyle: {
lineHeight: "1em",
fontSize: "0.85em",
},
columnGroupShow: "closed",
},
{
colId: "reservedCommand",
headerName: "전체",
width: 120,
cellRenderer: ({ data }: GenValueParams) => {
if (data.npc >= 2) {
return "NPC 장수";
}
const commandList = data.reservedCommand;
if (!commandList) {
return "???";
}
return commandList
.map(({ action, brief }) => {
if (action !== "휴식" || data.npc >= 2) {
return brief;
}
const limitMinutes = props.env.autorun_user?.limit_minutes ?? 0;
if (!limitMinutes) {
return brief;
}
if (data.killturn + limitMinutes > props.env.killturn) {
return "자율 행동";
}
return brief;
})
.join("<br>");
},
cellStyle: {
lineHeight: "1em",
fontSize: "0.85em",
},
columnGroupShow: "open",
},
],
},
turntime: {
colId: "turntime",
headerName: "턴",
field: "turntime",
width: 60,
valueFormatter: ({ value, data }) => {
if (!data.st1) {
return "?";
}
const turntime = value as string;
return turntime.substring(14, 19);
},
sortable: true,
cellClass: centerCellClass,
},
recent_war: {
colId: "recent_war",
headerName: "최근전투",
field: "recent_war",
width: 60,
valueFormatter: ({ value, data }) => {
if (!data.st1) {
return "?";
}
const turntime = value as string;
return turntime.substring(14, 19);
},
sortable: true,
cellClass: centerCellClass,
},
years: {
groupId: "years",
headerName: "연도",
children: [
{
colId: "years",
headerName: "요약",
width: 60,
cellRenderer: ({ data }: GenValueParams) => {
return `${data.age}세<br>${data.belong}`;
},
cellClass: centerCellClass,
columnGroupShow: "closed",
},
{
colId: "age",
headerName: "연령",
field: "age",
...sortableNumber,
valueFormatter: (v: GenValueParams) => `${v.value}`,
width: 60,
cellClass: centerCellClass,
columnGroupShow: "open",
},
{
colId: "belong",
headerName: "사관",
field: "belong",
...sortableNumber,
valueFormatter: (v: GenValueParams) => `${v.value}`,
width: 60,
cellClass: centerCellClass,
columnGroupShow: "open",
},
],
},
killturnAndConnect: {
groupId: "killturnAndConnect",
headerName: "기타",
children: [
{
colId: "killturnAndConnect",
headerName: "삭/벌",
cellRenderer: ({ data }: GenValueParams) => {
return `${data.killturn.toLocaleString()}턴<br>${data.connect.toLocaleString()}`;
},
cellClass: rightAlignClass,
columnGroupShow: "closed",
width: 70,
},
{
colId: "killturn",
headerName: "삭턴",
field: "killturn",
cellRenderer: ({ data }: GenValueParams) => {
return `${data.killturn.toLocaleString()}`;
},
...sortableNumber,
width: 70,
columnGroupShow: "open",
},
{
colId: "connect",
headerName: "벌점",
field: "connect",
cellRenderer: ({ data }: GenValueParams) => {
return `${data.connect.toLocaleString()}점<br>(${formatConnectScore(data.connect)})`;
},
...sortableNumber,
width: 70,
columnGroupShow: "open",
},
],
},
warResults: {
groupId: "warResults",
headerName: "전과",
children: [
{
colId: "warResults",
headerName: "요약",
cellRenderer: ({ data }: GenValueParams) => {
if (!data.st1) {
return "?";
}
const killRatePercent = Math.round((data.killcrew / Math.max(1, data.deathcrew)) * 100);
return `${data.warnum.toLocaleString()}${data.killnum.toLocaleString()}승<br>살상: ${killRatePercent}%`;
},
cellClass: centerCellClass,
columnGroupShow: "closed",
width: 90,
},
{
colId: "warnum",
headerName: "전투",
field: "warnum",
...sortableNumber,
valueFormatter: numberFormatter("전"),
columnGroupShow: "open",
width: 60,
},
{
colId: "killnum",
headerName: "승리",
field: "killnum",
...sortableNumber,
valueFormatter: numberFormatter("승"),
columnGroupShow: "open",
width: 60,
},
{
colId: "killcrew",
headerName: "살상률",
field: "killcrew",
...sortableNumber,
valueGetter: ({ data }) => {
if (!data.st1) {
return "?";
}
const killRatePercent = Math.round((data.killcrew / Math.max(1, data.deathcrew)) * 100);
return killRatePercent;
},
valueFormatter: numberFormatter("%"),
columnGroupShow: "open",
width: 60,
},
],
},
});
const columnDefs = ref([...Object.values(columnRawDefs.value)]);
watch(columnRawDefs, (val) => {
columnDefs.value = [...Object.values(val)];
gridApi.value?.refreshCells();
});
</script>
<style scoped lang="scss">
.g-tr {
border-bottom: solid gray 1px;
}
.g-thead-tr {
position: sticky;
top: 0px;
z-index: 5;
}
:deep(.view-mode-list) {
width: 180px;
}
</style>
<style lang="scss">
.component-general-list {
.clickable-cell:hover {
text-decoration: underline;
cursor: pointer;
}
.ag-root-wrapper .cell-middle {
display: flex;
align-items: center;
}
.ag-root-wrapper {
font-family: "Pretendard", "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic";
font-size: 14px;
overflow: auto;
}
.ag-root {
overflow: auto;
}
.ag-header {
position: sticky;
top: 0;
z-index: 10;
}
.cell-center {
justify-content: space-around;
text-align: center;
}
.cell-right {
justify-content: flex-end;
text-align: right;
}
.cell-sp .col {
min-width: 30px;
}
.ag-header-cell,
.ag-header-group-cell,
.ag-cell {
padding-left: 4px;
padding-right: 4px;
}
.ag-header-cell-label,
.ag-header-group-cell-label {
justify-content: center;
}
.ag-ltr .ag-floating-filter-button {
margin-left: 2px;
}
.ag-rtl .ag-floating-filter-button {
margin-right: 2px;
}
}
.general-list-toolbar {
.column-menu {
column-count: 3;
}
}
</style>
+148
View File
@@ -0,0 +1,148 @@
<template>
<div class="general-card-supplement row gx-0">
<div class="col-12 general-card-info">
<div class="part-title">추가 정보</div>
<div>명성</div>
<div>{{ formatHonor(general.experience) }} ({{ general.experience.toLocaleString() }})</div>
<div>계급</div>
<div>{{ general.dedLevelText }} ({{ general.dedication.toLocaleString() }}</div>
<div>봉급</div>
<div>{{ general.bill.toLocaleString() }}</div>
<div>전투</div>
<div>{{ general.warnum.toLocaleString() }}</div>
<div>계략</div>
<div>{{ general.firenum.toLocaleString() }}</div>
<div>사관</div>
<div>{{ general.belong }}년차</div>
<div>승률</div>
<div>{{ ((general.killnum / Math.max(general.warnum, 1)) * 100).toFixed(2) }} %</div>
<div>승리</div>
<div>{{ general.killnum.toLocaleString() }}</div>
<div>패배</div>
<div>{{ general.deathnum.toPrecision() }}</div>
<div>살상률</div>
<div>
{{
((general.killcrew / Math.max(general.deathcrew, 1)) * 100).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}}
%
</div>
<div>사살</div>
<div>{{ general.killcrew.toLocaleString() }}</div>
<div>피살</div>
<div>{{ general.deathcrew.toLocaleString() }}</div>
</div>
<div class="col-7 general-card-dex">
<div class="part-title">숙련도</div>
<template v-for="[dexType, dex, dexInfo] of dexList" :key="dexType">
<div>{{ dexType }}</div>
<div :style="{ color: dexInfo.color }">{{ dexInfo.name }}</div>
<div>{{ (dex / 1000).toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 }) }}K</div>
<div>
<SammoBar :height="10" :percent="dex / 1_000_000" />
</div>
</template>
</div>
<div class="col-5 general-card-turn">
<div class="part-title">예약턴</div>
<template v-if="general.reservedCommand">
<div v-for="(turn, idx) in general.reservedCommand.slice(0, 5)" :key="idx">
{{ turn.brief }}
</div>
</template>
<div v-else style="grid-row: 2 / 7">NPC</div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { GeneralListItemP1 } from "@/defs/API/Nation";
import { computed } from "vue";
import SammoBar from "@/components/SammoBar.vue";
import { formatDexLevel, type DexInfo } from "@/utilGame/formatDexLevel";
import { formatHonor } from "@/utilGame/formatHonor";
const props = defineProps<{
general: GeneralListItemP1;
}>();
const dexList = computed((): [string, number, DexInfo][] => {
return [
["보병", props.general.dex1, formatDexLevel(props.general.dex1)],
["궁병", props.general.dex2, formatDexLevel(props.general.dex2)],
["기병", props.general.dex3, formatDexLevel(props.general.dex3)],
["귀병", props.general.dex4, formatDexLevel(props.general.dex4)],
["차병", props.general.dex5, formatDexLevel(props.general.dex5)],
];
});
</script>
<style lang="scss" scoped>
.general-card-basic {
display: grid;
grid-template-columns: 64px repeat(3, 2fr 5fr);
grid-template-rows: repeat(9, calc(64px / 3));
text-align: center;
font-size: 14px;
}
.general-icon {
width: 64px;
height: 64px;
background-size: contain;
background-repeat: no-repeat;
grid-row: 1 / 4;
}
.general-name {
grid-row: 1 / 2;
grid-column: 2 / 8;
font-weight: bold;
}
.general-crew-type-icon {
width: 64px;
height: 64px;
background-size: contain;
background-repeat: no-repeat;
grid-row: 4 / 7;
}
.general-exp-level-bar {
grid-column: 3 / 6;
}
.general-defence-train {
grid-column: 2 / 4;
}
.general-card-info {
display: grid;
grid-template-columns: repeat(3, 64px 1fr);
grid-template-rows: repeat(5, calc(64px / 3));
.part-title {
grid-column: 1 / 4;
}
}
.general-card-dex {
display: grid;
grid-template-columns: 64px 40px 60px 1fr;
grid-template-rows: repeat(6, calc(64px / 3));
.part-title {
grid-column: 1 / 5;
}
}
.general-card-turn {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: repeat(6, calc(64px / 3));
}
</style>
+137
View File
@@ -0,0 +1,137 @@
<template>
<div
:class="['city_base', `city_base_${city.id}`, `city_level_${city.level}`]"
:style="cityPos"
@mouseenter="silent"
@mouseleave="silent"
>
<a
class="city_link"
:data-text="city.text"
:data-nation="city.nation"
:data-id="city.id"
:href="props.href"
:style="{
cursor: city.clickable ? 'pointer' : 'default',
}"
@click="clicked"
@touchend="touchend"
@mouseenter="mouseenter"
@mouseleave="mouseleave"
>
<div
class="city_img"
:style="{
backgroundColor: city.color,
}"
>
<div :class="['city_filler', props.isMyCity ? 'my_city' : '']"></div>
<div v-if="city.state > 0" :class="['city_state', `city_state_${getCityState()}`]"></div>
<div v-if="city.nationID && city.nationID > 0" class="city_flag">
<div v-if="city.isCapital" class="city_capital"></div>
</div>
<span class="city_detail_name">{{ city.name }}</span>
</div>
</a>
</div>
</template>
<script lang="ts" setup>
import type { MapCityParsed } from "@/map";
import { ref, toRef, watch, type PropType } from "vue";
const emit = defineEmits<{
(event: "click", evnet: MouseEvent | TouchEvent): void;
(event: "mouseenter", e: MouseEvent): void;
(event: "mouseleave", e: MouseEvent): void;
}>();
const props = defineProps({
city: {
type: Object as PropType<MapCityParsed>,
required: true,
},
href: {
type: String,
default: undefined,
required: false,
},
isMyCity: {
type: Boolean,
required: false,
defeault: false,
},
isFullWidth: {
type: Boolean,
required: true,
},
});
const city = toRef(props, "city");
const cityPos = ref({
left: "0px",
top: "0px",
});
watch(
() => props.isFullWidth,
(isFullWidth) => {
const { x, y } = city.value;
if (isFullWidth) {
cityPos.value = {
left: `${x - 20}px`,
top: `${y - 15}px`,
};
} else {
cityPos.value = {
left: `${(x * 5) / 7 - 20}px`,
top: `${(y * 5) / 7 - 18}px`,
};
}
},
{ immediate: true }
);
function getCityState(): string {
const state = city.value.state;
if (state < 10) {
return "good";
}
if (state < 40) {
return "bad";
}
if (state < 50) {
return "war";
}
return "wrong";
}
function clicked(event: MouseEvent) {
emit("click", event);
}
function mouseenter(event: MouseEvent) {
event.stopPropagation();
emit("mouseenter", event);
}
function mouseleave(event: MouseEvent) {
event.stopPropagation();
emit("mouseleave", event);
}
function touchend(event: TouchEvent) {
event.stopPropagation();
emit("click", event);
}
function silent(event: MouseEvent) {
event.stopPropagation();
}
</script>
<style lang="scss" scoped>
a,
div,
span {
line-height: 1.3;
font-size: 14px;
}
</style>
+154
View File
@@ -0,0 +1,154 @@
<template>
<div
:class="['city_base', `city_base_${city.id}`, `city_level_${city.level}`]"
:style="cityPos"
@mouseenter="silent"
@mouseleave="silent"
>
<div
v-if="city.color"
class="city_bg"
:style="{
backgroundImage: `url(${imagePath}/b${city.color.substring(1).toUpperCase()}.png)`,
}"
></div>
<a
class="city_link"
:data-text="city.text"
:data-nation="city.nation"
:data-id="city.id"
:href="props.href"
:style="{
cursor: city.clickable ? 'pointer' : 'default',
}"
@click="clicked"
@touchend="touchend"
@mouseenter="mouseenter"
@mouseleave="mouseleave"
>
<div class="city_img">
<img :src="`${imagePath}/cast_${city.level}.gif`" />
<div :class="['city_filler', props.isMyCity ? 'my_city' : '']"></div>
<div v-if="city.nationID && city.nationID > 0" class="city_flag">
<img :src="`${imagePath}/${city.supply ? 'f' : 'd'}${city.color.substring(1).toUpperCase()}.gif`" />
<div v-if="city.isCapital" class="city_capital">
<img :src="`${imagePath}/event51.gif`" />
</div>
</div>
<span class="city_detail_name">{{ city.name }}</span>
</div>
<div v-if="city.state > 0" class="city_state">
<img :src="`${imagePath}/event${city.state}.gif`" />
</div>
</a>
</div>
</template>
<script lang="ts" setup>
import type { MapCityParsed } from "@/map";
import { ref, toRef, watch, type PropType } from "vue";
const emit = defineEmits<{
(event: "click", e: MouseEvent | TouchEvent): void;
(event: "mouseenter", e: MouseEvent): void;
(event: "mouseleave", e: MouseEvent): void;
}>();
const props = defineProps({
city: {
type: Object as PropType<MapCityParsed>,
required: true,
},
href: {
type: String,
default: undefined,
required: false,
},
isMyCity: {
type: Boolean,
required: false,
defeault: false,
},
imagePath: {
type: String,
required: true,
},
isFullWidth: {
type: Boolean,
required: true,
},
});
const city = toRef(props, "city");
const cityPos = ref({
left: "0px",
top: "0px",
});
watch(
() => props.isFullWidth,
(isFullWidth) => {
const { x, y } = city.value;
if (isFullWidth) {
cityPos.value = {
left: `${x - 20}px`,
top: `${y - 15}px`,
};
} else {
cityPos.value = {
left: `${(x * 5) / 7 - 20}px`,
top: `${(y * 5) / 7 - 18}px`,
};
}
},
{ immediate: true }
);
const imagePath = toRef(props, "imagePath");
function clicked(event: MouseEvent) {
emit("click", event);
}
function mouseenter(event: MouseEvent) {
event.stopPropagation();
emit("mouseenter", event);
}
function mouseleave(event: MouseEvent) {
event.stopPropagation();
emit("mouseleave", event);
}
function touchend(event: TouchEvent) {
event.stopPropagation();
emit("click", event);
}
function silent(event: MouseEvent) {
event.stopPropagation();
}
</script>
<style lang="scss" scoped>
.full_width_map{
a,
div,
img,
span {
line-height: 1.3;
font-size: 14px;
}
}
.small_width_map {
a,
div,
img {
line-height: 1.3;
font-size: 14px;
}
span {
line-height: 1.0;
font-size: 11px;
}
}
</style>
+493
View File
@@ -0,0 +1,493 @@
<template>
<div
v-if="(mapData.version ?? 0) == CURRENT_MAP_VERSION"
:id="uuid"
:class="[
'world_map',
`map_theme_${mapTheme}`,
drawableMap ? '' : 'draw_required',
props.isDetailMap ? 'map_detail' : 'map_basic',
hideMapCityName ? 'hide_cityname' : '',
isFullWidth ? 'full_width_map' : 'small_width_map',
getMapSeasonClassName(),
]"
>
<div
v-my-tooltip.hover.top="{
class: 'map_title_tooltiptext',
}"
class="map_title"
:title="getTitleTooltip()"
>
<!-- eslint-disable-next-line vue/max-attributes-per-line -->
<span class="map_title_text" :style="{ color: getTitleColor() }"
>{{ mapData?.year }} {{ mapData?.month }}</span
>
<span class="tooltiptext" />
</div>
<div ref="map_area" class="map_body" @click="clickOutside">
<div class="map_bglayer1" />
<div class="map_bglayer2" />
<div class="map_bgroad" />
<div class="map_button_stack">
<button
type="button"
:class="['btn btn-primary map_toggle_cityname btn-sm btn-minimum', hideMapCityName ? 'active' : '']"
data-bs-toggle="button"
:aria-pressed="hideMapCityName"
autocomplete="off"
@click="hideMapCityName = !hideMapCityName"
>
도시명 표기</button
><br />
<button
:style="{
display: deviceType != 'mouseOnly' ? 'block' : 'none',
}"
type="button"
:class="['btn btn-secondary map_toggle_single_tap btn-sm btn-minimum', toggleSingleTap ? 'active' : '']"
data-bs-toggle="button"
:aria-pressed="toggleSingleTap"
autocomplete="off"
@click="toggleSingleTap = !toggleSingleTap"
>
두번 도시 이동
</button>
</div>
<template v-if="drawableMap === undefined"><!--로딩중?--></template>
<template v-else-if="props.isDetailMap">
<MapCityDetail
v-for="city of drawableMap.cityList"
:key="city.id"
:city="city"
:image-path="imagePath"
:is-my-city="city.id === drawableMap.myCity"
:isFullWidth="isFullWidth"
:href="props.genHref?.call(city, city.id)"
@click="cityClick(city, $event)"
@mouseenter="mouseenter(city, $event)"
@mouseleave="mouseleave(city, $event)"
/>
</template>
<template v-else
><MapCityBasic
v-for="city of drawableMap.cityList"
:key="city.id"
:city="city"
:is-my-city="city.id === drawableMap.myCity"
:isFullWidth="isFullWidth"
:href="props.genHref?.call(city, city.id)"
@click="cityClick(city, $event)"
@mouseenter="mouseenter(city, $event)"
@mouseleave="mouseleave(city, $event)"
/></template>
</div>
<div
ref="tooltipDom"
class="city_tooltip"
:style="{
display: isOutside || !activatedCity ? 'none' : 'block',
position: 'absolute',
left: `${(() => {
if (cursorX + tooltipWidth + 10 > (isFullWidth ? 700 : 500)) {
return cursorX - tooltipWidth - 5;
}
return cursorX + 10;
})()}px`,
top: `${cursorY + 30}px`,
}"
>
<div class="city_name">{{ activatedCity?.text }}</div>
<div class="nation_name">{{ activatedCity?.nation }}</div>
</div>
</div>
<div v-else class="world_map">
<span class="map_title_text">
버전이 맞지 않습니다.<br />
렌더러 버전: {{ CURRENT_MAP_VERSION }}<br />
API 버전: {{ mapData.version ?? 0 }}
</span>
</div>
</template>
<script lang="ts">
export type MapCityParsedRaw = {
id: number;
level: number;
state: number;
nationID?: number;
region: number;
supply: boolean;
};
type MapCityParsedName = MapCityParsedRaw & {
name: string;
x: number;
y: number;
};
type MapCityParsedNation = MapCityParsedName & {
nationID?: number;
nation?: string;
color?: string;
isCapital: boolean;
};
type MapCityParsedClickable = MapCityParsedNation & {
clickable: number;
};
type MapCityParsedRegionLevelText = MapCityParsedClickable & {
region_str: string;
level_str: string;
text: string;
};
export type MapCityParsed = MapCityParsedRegionLevelText;
type MapCityDrawable = {
cityList: MapCityParsed[];
myCity?: number;
};
type MapNationParsed = {
id: number;
name: string;
color: string;
capital: number;
};
export type CityPositionMap = {
[cityID: number]: [string, number, number];
};
</script>
<script lang="ts" setup>
import "@/../scss/map.scss";
import { type PropType, toRef, inject, type Ref, ref, watch, type ComponentPublicInstance } from "vue";
import { v4 as uuidv4 } from "uuid";
import { CURRENT_MAP_VERSION, type MapResult } from "@/defs";
import { joinYearMonth } from "@/util/joinYearMonth";
import { parseYearMonth } from "@/util/parseYearMonth";
import vMyTooltip from "@/directives/vMyTooltip";
import type { GameConstStore } from "@/GameConstStore";
import { unwrap_err } from "@/util/unwrap_err";
import { getMaxRelativeTechLevel, TECH_LEVEL_YEAR_GAP } from "@/utilGame/techLevel";
import { deviceType } from "detect-it";
import MapCityBasic from "./MapCityBasic.vue";
import MapCityDetail from "./MapCityDetail.vue";
import { convertDictById } from "@/common_legacy";
import { useElementSize, useMouse, useMouseInElement } from "@vueuse/core";
import { hideMapCityName, toggleSingleTap } from "@/state/mapViewer";
import { is1000pxMode } from "@/state/is1000pxMode";
const uuid = uuidv4();
const gameConstStore = unwrap_err(
inject<Ref<GameConstStore>>("gameConstStore"),
Error,
"gameConstStore가 주입되지 않았습니다."
);
const tooltipDom = ref<ComponentPublicInstance<HTMLDivElement>>();
const map_area = ref<ComponentPublicInstance<HTMLDivElement>>();
const { elementX: cursorX, elementY: cursorY, isOutside } = useMouseInElement(map_area);
const tooltipWidth = ref(0);
const { width: tooltipCurrWidth } = useElementSize(tooltipDom);
watch(
tooltipCurrWidth,
(newWidth) => {
if (newWidth == 0) return;
tooltipWidth.value = newWidth;
},
{ immediate: true }
);
const { sourceType: cursorType } = useMouse();
const emit = defineEmits<{
(event: "city-click", city: MapCityParsed, e: MouseEvent | TouchEvent): void;
(event: "parsed", drawable: MapCityDrawable): void;
(event: "update:modelValue", value: MapCityParsed): void;
}>();
const isFullWidth = ref(true);
function setWidthMode([widthMode, is1000pxMode]: ["auto" | "full" | "small" | undefined, boolean]): void {
if (widthMode == "full") {
isFullWidth.value = true;
}
if (widthMode == "small") {
isFullWidth.value = false;
}
isFullWidth.value = is1000pxMode;
}
watch([() => props.width, is1000pxMode], setWidthMode, { immediate: true });
const props = defineProps({
width: {
type: String as PropType<"full" | "small" | "auto" | undefined>,
default: undefined,
required: false,
},
imagePath: {
type: String,
required: true,
},
mapName: {
type: String,
required: true,
},
isDetailMap: { type: Boolean, default: undefined, required: false },
disallowClick: { type: Boolean, default: undefined, required: false },
genHref: {
type: Function as PropType<(cityID: number) => string>,
default: undefined,
required: false,
},
cityPosition: {
type: Object as PropType<CityPositionMap>,
required: true,
},
formatCityInfo: {
type: Function as PropType<(city: MapCityParsed) => MapCityParsed>,
required: true,
},
mapData: {
type: Object as PropType<MapResult>,
required: true,
},
modelValue: {
type: Object as PropType<MapCityParsed>,
default: undefined,
required: false,
},
});
const mapData = toRef(props, "mapData");
const mapTheme = toRef(props, "mapName");
function getTitleColor(): string | undefined {
const { startYear, year } = mapData.value;
if (year < startYear + 1) {
return "magenta";
}
if (year < startYear + 2) {
return "orange";
}
if (year < startYear + 3) {
return "yellow";
}
}
const drawableMap = ref<MapCityDrawable>(convertCityObjs(props.mapData));
const activatedCity = ref<MapCityParsed>();
function getBeginGameLimitTooltip(): string | undefined {
const { startYear, year, month } = mapData.value;
if (year > startYear + 3) return undefined;
const [remainYear, remainMonth] = parseYearMonth(joinYearMonth(startYear + 3, 0) - joinYearMonth(year, month));
return `초반제한 기간 : ${remainYear}${remainMonth > 0 ? ` ${remainMonth}개월` : ""} (${startYear + 3}년)`;
}
function getTitleTooltip(): string {
const result: string[] = [];
const beginLimit = getBeginGameLimitTooltip();
if (beginLimit) {
result.push(beginLimit);
}
const { startYear, year } = mapData.value;
const maxTechLevel = gameConstStore.value.gameConst.maxTechLevel;
const currentTechLimit = getMaxRelativeTechLevel(startYear, year, maxTechLevel);
if (currentTechLimit == maxTechLevel) {
result.push(`기술등급 제한 : ${currentTechLimit}등급 (최종)`);
} else {
const nextTechLimitYear = currentTechLimit * TECH_LEVEL_YEAR_GAP + startYear;
result.push(`기술등급 제한 : ${currentTechLimit}등급 (${nextTechLimitYear}년 해제)`);
}
return result.join("<br>");
}
function getMapSeasonClassName(): string {
const { month } = mapData.value;
if (month <= 3) {
return "map_spring";
}
if (month <= 6) {
return "map_summer";
}
if (month <= 9) {
return "map_fall";
}
return "map_winter";
}
function convertCityObjs(obj: MapResult): MapCityDrawable {
//원본 Obj는 굉장히 간소하게 온다, Object 형태로 변환해서 사용한다.
function toCityObj([id, level, state, nationID, region, supply]: MapResult["cityList"][0]): MapCityParsedRaw {
return {
id: id,
level: level,
state: state,
nationID: nationID > 0 ? nationID : undefined,
region: region,
supply: supply != 0,
};
}
function toNationObj([id, name, color, capital]: MapResult["nationList"][0]): MapNationParsed {
return {
id,
name,
color,
capital,
};
}
const nationList = convertDictById(obj.nationList.map(toNationObj)); //array of object -> dict
const spyList = obj.spyList;
const shownByGeneralList = new Set(obj.shownByGeneralList);
const myCity = obj.myCity;
const myNation = obj.myNation;
function mergePositionInfo(city: MapCityParsedRaw): MapCityParsedName {
const id = city.id;
if (!(id in props.cityPosition)) {
throw TypeError(`알수 없는 cityID: ${id}`);
}
const [name, x, y] = props.cityPosition[id];
return {
...city,
name,
x,
y,
};
}
function mergeNationInfo(city: MapCityParsedName): MapCityParsedNation {
//nationID 값으로 isCapital, color, nation을 통합
const nationID = city.nationID;
if (nationID === undefined || !(nationID in nationList)) {
return {
...city,
isCapital: false,
};
}
const nationObj = nationList[nationID];
return {
...city,
nation: nationObj.name,
color: nationObj.color,
isCapital: nationObj.capital == city.id,
};
}
function mergeClickable(city: MapCityParsedNation): MapCityParsedClickable {
//clickable = (defaultCity << 4 ) | (remainSpy << 3) | (ourCity << 2) | (shownByGeneral << 1)
const id = city.id;
const nationID = city.nationID;
if (props.disallowClick) {
return { ...city, clickable: 0 };
}
let clickable = 16;
if (id in spyList) {
clickable |= spyList[id] << 3;
}
if (myNation !== null && nationID == myNation) {
clickable |= 4;
}
if (shownByGeneralList.has(id)) {
clickable |= 2;
}
if (myCity !== null && id == myCity) {
clickable |= 2;
}
return {
...city,
clickable,
};
}
const cityList = obj.cityList
.map(toCityObj)
.map(mergePositionInfo)
.map(mergeNationInfo)
.map(mergeClickable)
.map(window.formatCityInfo);
const result = {
cityList: cityList,
myCity: myCity,
};
emit("parsed", result);
return result;
}
watch(
() => props.mapData,
(mapInfo) => {
activatedCity.value = undefined;
drawableMap.value = convertCityObjs(mapInfo);
}
);
const touchState = ref(0);
function cityClick(city: MapCityParsed, $event: MouseEvent | TouchEvent): void {
if (cursorType.value == "touch") {
if (touchState.value == 1 && activatedCity.value?.id !== city.id) {
touchState.value = 0;
activatedCity.value = undefined;
}
if (touchState.value == 0) {
touchState.value = 1;
activatedCity.value = city;
$event.preventDefault();
if (!toggleSingleTap.value) {
return;
}
}
}
emit("city-click", city, $event);
emit("update:modelValue", city);
}
function clickOutside($event: MouseEvent): void {
$event.preventDefault();
$event.stopPropagation();
if (touchState.value == 1) {
touchState.value = 0;
activatedCity.value = undefined;
}
}
function mouseenter(city: MapCityParsed, $event: MouseEvent): void {
if (cursorType.value == "mouse") {
activatedCity.value = city;
touchState.value = 0;
}
}
function mouseleave(city: MapCityParsed, $event: MouseEvent): void {
if (cursorType.value == "mouse") {
activatedCity.value = undefined;
touchState.value = 0;
}
}
</script>
+131
View File
@@ -0,0 +1,131 @@
<template>
<div class="row form-group number-input-with-info">
<label v-if="!right && title" class="col col-form-label">{{ title }}</label>
<div class="col">
<input
ref="input"
v-model="rawValue"
type="number"
:step="step ?? undefined"
class="form-control f_tnum"
:min="min ?? undefined"
:max="max ?? undefined"
:style="{ display: editmode ? undefined : 'none' }"
@blur="onBlurNumber"
@input="updateValue"
/>
<input
type="text"
class="form-control f_tnum"
:readonly="readonly"
:value="printValue"
:style="{ display: !editmode ? undefined : 'none' }"
@focus="onFocusText"
/>
</div>
<label v-if="right && title" class="col col-form-label">{{ title }}</label>
</div>
<div style="text-align: right">
<small class="form-text text-muted"><slot /></small>
</div>
</template>
<script lang="ts">
import { clamp } from "lodash";
import { defineComponent } from "vue";
export default defineComponent({
name: "NumberInputWithInfo",
props: {
readonly: {
type: Boolean,
required: false,
default: false,
},
int: {
type: Boolean,
required: false,
default: true,
},
title: {
type: String,
required: false,
default: null,
},
min: {
type: Number,
required: false,
default: 0,
},
max: {
type: Number,
default: undefined,
required: false,
},
step: {
type: Number,
default: undefined,
required: false,
},
modelValue: {
type: Number,
default: 0,
},
right: {
type: Boolean,
required: false,
default: false,
},
},
emits: ["update:modelValue"],
data() {
return {
editmode: false,
rawValue: this.modelValue,
printValue: this.modelValue.toLocaleString(),
};
},
watch: {
modelValue: function (newVal: number) {
this.rawValue = newVal;
this.printValue = newVal.toLocaleString();
},
},
methods: {
updateValue() {
if (this.readonly) {
return;
}
if (this.int) {
this.rawValue = Math.floor(this.rawValue);
}
this.printValue = this.rawValue.toLocaleString();
if (this.min !== undefined || this.max !== undefined) {
const clampedValue = clamp(this.rawValue, this.min ?? this.rawValue, this.max ?? this.rawValue);
this.$emit("update:modelValue", clampedValue);
} else {
this.$emit("update:modelValue", this.rawValue);
}
},
onBlurNumber() {
this.editmode = false;
this.printValue = this.rawValue.toLocaleString();
if (this.min !== undefined || this.max !== undefined) {
const clampedValue = clamp(this.rawValue, this.min ?? this.rawValue, this.max ?? this.rawValue);
if (clampedValue !== this.rawValue) {
this.rawValue = clampedValue;
this.updateValue();
}
}
},
onFocusText() {
if (this.readonly) {
return;
}
this.editmode = true;
setTimeout(() => {
(this.$refs.input as HTMLInputElement).focus();
}, 0);
},
},
});
</script>
+35
View File
@@ -0,0 +1,35 @@
<template>
<div
v-b-tooltip.hover.top
:title="`${props.percent}$`"
class="sammo-bar"
:style="{
height: `${props.height}px`,
backgroundImage: `url(${imagePath}/pr${props.height - 2}.png)`,
backgroundRepeat: 'repeat-x',
backgroundPosition: 'center',
borderTop: 'solid 1px #888',
borderBottom: 'solid 1px #333',
}"
>
<div
class="sammo-bar-in"
:style="{
height: `${props.height}px`,
backgroundImage: `url(${imagePath}/pb${props.height - 2}.png)`,
backgroundRepeat: 'repeat-x',
backgroundPosition: 'left center',
width: `${clamp(props.percent, 0, 100)}%`,
}"
/>
</div>
</template>
<script lang="ts" setup>
import { clamp } from "lodash";
const imagePath = window.pathConfig.gameImage;
const props = defineProps<{
height: 7 | 10;
percent: number;
}>();
</script>
+46
View File
@@ -0,0 +1,46 @@
<template>
<span class="time-zone">{{ serverNow }}</span>
</template>
<script lang="ts" setup>
import { addMilliseconds } from "date-fns";
import { type PropType, ref, onMounted, watch } from "vue";
import { formatTime } from "@/util/formatTime";
const props = defineProps({
serverTime: {
type: Object as PropType<Date>,
required: false,
default: new Date(),
},
timeFormat: {
type: String,
required: false,
default: "HH:mm:ss",
},
});
const timeDiff = ref(0);
const serverNow = ref("");
watch(
() => props.serverTime,
(newValue) => {
const clientNow = new Date();
timeDiff.value = newValue.getTime() - clientNow.getTime();
}
);
function updateNow() {
const serverNowObj = addMilliseconds(new Date(), timeDiff.value);
serverNow.value = formatTime(serverNowObj, props.timeFormat);
setTimeout(() => {
updateNow();
}, 1000 - serverNowObj.getMilliseconds());
}
onMounted(() => {
const clientNow = new Date();
timeDiff.value = props.serverTime.getTime() - clientNow.getTime();
updateNow();
});
</script>
+72
View File
@@ -0,0 +1,72 @@
<template>
<table class="simple_nation_list">
<thead>
<tr>
<th style="width: 44%">국명</th>
<th style="width: 23%">국력</th>
<th style="width: 15%">장수</th>
<th style="width: 15%">속령</th>
</tr>
</thead>
<tbody>
<tr v-for="nation of nations" :key="nation.nation">
<td>
<span
:style="{
color: isBrightColor(nation.color) ? '#000' : '#fff',
backgroundColor: nation.color,
}"
>{{ nation.name }}</span
>
</td>
<td style="text-align: right">{{ nation.power.toLocaleString() }}</td>
<td style="text-align: right">{{ nation.gennum.toLocaleString() }}</td>
<td v-b-tooltip.hover style="text-align: right" :title="(nation.cities ?? []).join(', ')">
{{ (nation.cities ?? []).length }}
</td>
</tr>
</tbody>
<tfoot></tfoot>
</table>
</template>
<script lang="ts" setup>
import type { SimpleNationObj } from "@/defs";
import type { PropType } from "vue";
import { isBrightColor } from "@/util/isBrightColor";
defineProps({
nations: {
type: Array as PropType<SimpleNationObj[]>,
required: true,
},
});
</script>
<style lang="scss" scoped>
.simple_nation_list {
width: 100%;
thead {
background-color: #cccccc;
color: black;
text-align: center;
}
th {
border: 0;
border-left: 1px solid gray;
padding: 2px 6px;
}
td {
border: 0;
border-left: 1px solid gray;
padding: 1px 6px;
text-align: right;
}
td:first-child {
text-align: left;
}
}
</style>
+516
View File
@@ -0,0 +1,516 @@
<template>
<BButtonToolbar v-if="editable && editor" key-nav class="bg-dark">
<BButtonGroup class="mx-1">
<BButton v-b-tooltip.hover title="되돌리기" @click="editor?.commands.undo()">
<i class="bi bi-arrow-90deg-left" />
</BButton>
<BButton v-b-tooltip.hover title="재실행" @click="editor?.commands.redo()">
<i class="bi bi-arrow-90deg-right" />
</BButton>
</BButtonGroup>
<BButtonGroup class="mx-1">
<BButton
v-b-tooltip.hover
:class="{ 'is-active': editor.isActive('bold') }"
title="진하게"
@click="editor?.chain().focus().toggleBold().run()"
>
<i class="bi bi-type-bold" />
</BButton>
<BButton
v-b-tooltip.hover
:class="{ 'is-active': editor.isActive('italic') }"
title="기울이기"
@click="editor?.chain().focus().toggleItalic().run()"
>
<i class="bi bi-type-italic" />
</BButton>
<BButton
v-b-tooltip.hover
:class="{ 'is-active': editor.isActive('underline') }"
title="밑줄"
@click="editor?.chain().focus().toggleUnderline().run()"
>
<i class="bi bi-type-underline" />
</BButton>
<!-- 효과 지우기 -->
</BButtonGroup>
<BButtonGroup class="mx-1">
<BDropdown>
<template #button-content> 크기 </template>
<BDropdownItem @click="editor?.chain().focus().unsetFontSize().run()">
<span>기본</span>
</BDropdownItem>
<BDropdownDivider />
<BDropdownItem
v-for="sizeItem in fontSize"
:key="sizeItem"
@click="editor?.chain().focus().setFontSize(sizeItem).run()"
>
<span
:style="{
'font-size': sizeItem,
'text-decoration': editor.isActive('textStyle', {
fontSize: sizeItem,
})
? 'underline'
: undefined,
}"
>{{ sizeItem }}</span
>
</BDropdownItem>
</BDropdown>
<!-- 글꼴 -->
</BButtonGroup>
<BButtonGroup class="mx-1">
<BButton
v-b-tooltip.hover
:class="{ 'is-active': editor.isActive('strike') }"
title="가로선"
@click="editor?.chain().focus().toggleStrike().run()"
>
<i class="bi bi-type-strikethrough" />
</BButton>
<!-- 윗첨자, 아랫첨자 -->
</BButtonGroup>
<BButtonGroup class="mx-1">
<BButton
v-b-tooltip.hover
title="색상 취소"
@click="editor?.chain().focus().unsetColor().unsetBackgroundColor().run()"
>
<i class="bi bi-droplet" />
</BButton>
<input
v-b-tooltip.hover
type="color"
class="form-control form-control-color"
:value="colorConvert(editor.getAttributes('textStyle').color, '#ffffff')"
title="글자색"
@input="editor?.chain().focus().setColor(($event.target as HTMLInputElement).value).run()"
/>
<input
v-b-tooltip.hover
type="color"
class="form-control form-control-color"
:value="colorConvert(editor.getAttributes('textStyle').backgroundColor, '#000000')"
title="배경색"
@input="
editor?.chain().focus().setBackgroundColor(($event.target as HTMLInputElement).value).run()
"
/>
</BButtonGroup>
<BButtonGroup class="mx-1">
<BButton v-b-tooltip.hover title="이미지 추가" @click="showImageModal = true">
<i class="bi bi-image" />
</BButton>
<!-- 이미지추가 -->
<!-- 링크 -->
<!-- 영상링크 -->
<!-- -->
<!-- 구분선 삽입 -->
<BButton v-b-tooltip.hover title="구분선" @click="editor?.chain().focus().setHorizontalRule().run()">
<i class="bi bi-hr" />
</BButton>
</BButtonGroup>
<BButtonGroup class="mx-1">
<!-- 글머리 기호 -->
<!-- 번호 매기기 -->
<BButton
v-b-tooltip.hover
:class="{ 'is-active': editor.isActive({ textAlign: 'left' }) }"
title="왼쪽 정렬"
@click="editor?.chain().focus().setTextAlign('left').run()"
>
<i class="bi bi-text-left" />
</BButton>
<BButton
v-b-tooltip.hover
:class="{ 'is-active': editor.isActive({ textAlign: 'center' }) }"
title="가운데 정렬"
@click="editor?.chain().focus().setTextAlign('center').run()"
>
<i class="bi bi-text-center" />
</BButton>
<BButton
v-b-tooltip.hover
:class="{ 'is-active': editor.isActive({ textAlign: 'right' }) }"
title="오른쪽 정렬"
@click="editor?.chain().focus().setTextAlign('right').run()"
>
<i class="bi bi-text-right" />
</BButton>
<!-- 문단정렬(, , , )(내어, 들여) -->
</BButtonGroup>
<BButtonGroup class="mx-1" />
<BButtonGroup class="mx-1">
<!-- 줄간격 (1.0, 1.2, 1.4, 1.5, 1.6, 1.8, 2.0, 3.0) -->
</BButtonGroup>
<BButtonGroup class="mx-1">
<!-- 원본 코드 -->
</BButtonGroup>
</BButtonToolbar>
<BubbleMenu
v-if="editable && editor"
v-show="editor.isActive('custom-image')"
:tippyOptions="{ animation: false, maxWidth: 600 }"
:editor="editor"
>
<BButtonToolbar>
<BButtonGroup class="mx-1">
<BButton
v-b-tooltip.hover
:class="{
'is-active': editor.isActive('custom-image', {
size: 'small',
}),
f_frac: true,
}"
title="1/4 너비로 채우기"
@click="editor?.chain().focus().setImageEx({ size: 'small' }).run()"
>
1/4
</BButton>
<BButton
v-b-tooltip.hover
:class="{
'is-active': editor.isActive('custom-image', {
size: 'medium',
}),
f_frac: true,
}"
title="1/2 너비로 채우기"
@click="editor?.chain().focus().setImageEx({ size: 'medium' }).run()"
>
1/2
</BButton>
<BButton
v-b-tooltip.hover
:class="{
'is-active': editor.isActive('custom-image', {
size: 'large',
}),
f_frac: true,
}"
title="가득 채우기"
@click="editor?.chain().focus().setImageEx({ size: 'large' }).run()"
>
1
</BButton>
<BButton
:class="{
'is-active': editor.isActive('custom-image', {
size: 'original',
}),
}"
@click="editor?.chain().focus().setImageEx({ size: 'original' }).run()"
>
원본
</BButton>
</BButtonGroup>
<BButtonGroup class="mx-1">
<BButton
v-b-tooltip.hover
:class="{
'is-active': editor.isActive('custom-image', {
float: 'float-left',
}),
}"
title="왼쪽으로 붙이기"
@click="editor?.chain().focus().setImageEx({ align: 'float-left' }).run()"
>
<i class="bi bi-chevron-bar-left" />
</BButton>
<BButton
v-b-tooltip.hover
:class="{
'is-active': editor.isActive('custom-image', {
float: 'left',
}),
}"
title="왼쪽으로"
@click="editor?.chain().focus().setImageEx({ align: 'left' }).run()"
>
<i class="bi bi-align-start" />
</BButton>
<BButton
v-b-tooltip.hover
:class="{
'is-active': editor.isActive('custom-image', {
float: 'center',
}),
}"
title="가운데로"
@click="editor?.chain().focus().setImageEx({ align: 'center' }).run()"
>
<i class="bi bi-align-center" />
</BButton>
<BButton
v-b-tooltip.hover
:class="{
'is-active': editor.isActive('custom-image', {
float: 'right',
}),
}"
title="오른쪽으로 붙이기"
@click="editor?.chain().focus().setImageEx({ align: 'right' }).run()"
>
<i class="bi bi-align-end" />
</BButton>
<BButton
v-b-tooltip.hover
:class="{
'is-active': editor.isActive('custom-image', {
float: 'float-right',
}),
}"
title="오른쪽으로 붙이기"
@click="editor?.chain().focus().setImageEx({ align: 'float-right' }).run()"
>
<i class="bi bi-chevron-bar-right" />
</BButton>
</BButtonGroup>
</BButtonToolbar>
</BubbleMenu>
<EditorContent :editor="editor" class="tiptap-editor" />
<BModal
v-model="showImageModal"
title="이미지 추가"
okTitle="추가"
cancelTitle="취소"
@ok="tryAddImage"
@show="resetModal"
@hidden="resetModal"
>
<div class="bg-light text-dark">
<BFormGroup
label-cols-sm="4"
label-cols-lg="3"
content-cols-sm
content-cols-lg="7"
description="업로드할 파일을 선택해주세요. (jpg, png, gif, webp)"
label="이미지 업로드"
label-align="right"
:label-for="`${uuid}_image_upload`"
>
<input
:id="`${uuid}_image_upload`"
class="form-control"
type="file"
accept=".jpg,.jpeg,.png,.gif,.webp"
@change="chooseImage"
/>
</BFormGroup>
<BFormGroup
label-cols-sm="4"
label-cols-lg="3"
content-cols-sm
content-cols-lg="7"
description="링크할 이미지 주소를 입력해주세요."
label="이미지 링크"
label-align="right"
:label-for="`${uuid}_image_link`"
>
<BFormInput v-model="imageLink" />
</BFormGroup>
</div>
</BModal>
</template>
<script lang="ts" setup>
//import "@scss/common/bootstrap5.scss";
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { Editor, EditorContent, BubbleMenu } from "@tiptap/vue-3";
import { FontSize } from "@/tiptap-ext/FontSize";
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
import TextStyle from "@tiptap/extension-text-style";
import TextAlign from "@tiptap/extension-text-align";
import Color from "@tiptap/extension-color";
//import Image from "@tiptap/extension-image";
import CustomImage from "@/tiptap-ext/CustomImage";
import Link from "@tiptap/extension-link";
import { BackgroundColor } from "@/tiptap-ext/BackgroundColor";
import {
BButtonGroup,
BButtonToolbar,
BButton,
BDropdown,
BDropdownItem,
BDropdownDivider,
BModal,
BFormGroup,
BFormInput,
} from "bootstrap-vue-3";
import { v4 as uuidv4 } from "uuid";
import { unwrap } from "@/util/unwrap";
import { getBase64FromFileObject } from "@/util/getBase64FromFileObject";
import { isObject, isString } from "lodash";
import type { AxiosError } from "axios";
import { SammoAPI } from "@/SammoAPI";
const props = defineProps({
modelValue: {
type: String,
default: "",
},
editable: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(["ready", "update:modelValue"]);
const uuid = ref(uuidv4());
const editor = ref<InstanceType<typeof Editor>>();
//const fontList = ref(["Pretendard", "맑은 고딕", "궁서", "돋움"]);
const fontSize = ref(["8px", "10px", "12px", "14px", "18px", "22px", "28px", "36px", "48px", "72px"]);
const imageUploadFiles = ref(null as FileList | null);
const imageLink = ref("");
const showImageModal = ref(false);
watch(
() => props.modelValue,
(value: string) => {
const isSame = editor.value?.getHTML() === value;
if (isSame) {
return;
}
editor.value?.commands.setContent(value, false);
}
);
watch(
() => props.editable,
(value: boolean) => {
if (!editor.value) {
return;
}
editor.value.options.editable = value;
if (value == true) {
editor.value.commands.focus();
}
}
);
onMounted(() => {
const vEditor = new Editor({
extensions: [
StarterKit,
Underline,
FontSize,
TextStyle,
TextAlign.configure({
types: ["heading", "paragraph"],
}),
Color.configure({
types: ["textStyle"],
}),
BackgroundColor.configure({
types: ["textStyle"],
}),
CustomImage,
Link,
],
editable: props.editable,
content: props.modelValue,
onUpdate: () => {
emit("update:modelValue", editor.value?.getHTML());
},
onCreate: () => {
emit("ready");
},
});
editor.value = vEditor;
});
onBeforeUnmount(() => {
editor.value?.destroy();
});
function chooseImage(e: Event) {
const target = unwrap(e.target) as HTMLInputElement;
imageUploadFiles.value = target.files;
}
function colorConvert(val: string | undefined, defaultVal: string) {
if (!val) {
return defaultVal;
}
if (val.startsWith("rgb")) {
const rgb = val.split("(")[1].split(")")[0].split(",");
const vals: string[] = [];
for (const subColor of rgb) {
const hexSubColor = parseInt(subColor).toString(16);
if (hexSubColor.length == 1) {
vals.push("0");
}
vals.push(hexSubColor);
}
return `#${vals.join("")}`;
}
return val;
}
async function tryAddImage(bvModalEvt: Event) {
if (imageUploadFiles.value === null || imageUploadFiles.value.length == 0) {
addImageLink(bvModalEvt);
return;
}
const targetImage = unwrap(unwrap(imageUploadFiles.value).item(0));
let imageResult: {
result: true;
path: string;
};
try {
const base64Binary = await getBase64FromFileObject(targetImage);
imageResult = await SammoAPI.Misc.UploadImage({
imageData: base64Binary,
});
} catch (e) {
if (isString(e)) {
alert(e);
bvModalEvt.preventDefault();
}
if (isObject(e) && "response" in e) {
const axiosErr = e as AxiosError;
if (axiosErr.response?.status === 413) {
alert("허용 용량을 초과했습니다.");
bvModalEvt.preventDefault();
}
}
console.error(e);
return false;
}
const imagePath = imageResult.path;
editor.value?.chain().focus().setImageEx({ src: imagePath }).run();
}
function addImageLink(bvModalEvt: Event) {
if (!imageLink.value) {
alert("업로드할 이미지를 선택하거나, 이미지 주소를 입력해주세요.");
bvModalEvt.preventDefault();
return false;
}
editor.value?.chain().focus().setImageEx({ src: imageLink.value }).run();
}
function resetModal() {
imageLink.value = "";
imageUploadFiles.value = null;
}
</script>
+125
View File
@@ -0,0 +1,125 @@
<template>
<div :class="['bg0', 'back_bar', teleportZone ? 'back_bar_teleport' : undefined]">
<button type="button" class="btn btn-sammo-base2 back_btn" @click="back">
{{ props.type == "close" ? " 닫기" : "돌아가기" }}</button
><button v-if="reloadable" type="button" class="btn btn-sammo-base2 reload_btn" @click="reload">갱신</button>
<div v-else />
<h2 class="title">
{{ title }}
</h2>
<template v-if="hasSlot">
<slot></slot>
</template>
<div v-else-if="teleportZone" :id="teleportZone" class="teleport-zone"></div>
<template v-else>
<div>&nbsp;</div>
<b-button
v-if="toggleSearch !== undefined"
class="btn-toggle-zoom"
:variant="toggleSearch ? 'info' : 'secondary'"
:pressed="toggleSearch"
@click="toggleSearch = !toggleSearch"
>
{{ toggleSearch ? "검색 켜짐" : "검색 꺼짐" }}
</b-button>
</template>
</div>
</template>
<script lang="ts" setup>
import "@scss/game_bg.scss";
import { type PropType, ref, watch, useSlots } from "vue";
import VueTypes from "vue-types";
const props = defineProps({
title: VueTypes.string.isRequired,
type: {
type: String as PropType<"normal" | "chief" | "close">,
default: "normal",
required: false,
},
searchable: {
type: Boolean,
default: undefined,
required: false,
},
reloadable: {
type: Boolean,
default: undefined,
required: false,
},
teleportZone: {
type: String,
default: undefined,
required: false,
},
});
const slots = useSlots();
console.log(slots);
const hasSlot = !!slots['default'];
const emit = defineEmits(["update:searchable", "reload"]);
const toggleSearch = ref(props.searchable);
watch(toggleSearch, (val) => {
emit("update:searchable", val);
});
function back() {
if (props.type === "normal") {
location.href = "./";
} else if (props.type == "chief") {
location.href = "v_chiefCenter.php";
} else {
//TODO: window.close하려면 부모창이 있어야함!
window.close();
}
}
function reload() {
emit("reload");
}
</script>
<style scoped>
.back_bar {
max-width: 1000px;
width: 100%;
margin: auto;
display: grid;
grid-template-columns: 90px 90px 1fr 90px 90px;
position: relative;
height: 24pt;
}
.back_bar.back_bar_teleport {
grid-template-columns: 90px 90px 1fr 180px;
}
.reload_btn {
height: 24pt;
margin-right: 2px;
}
.teleport-zone {
height: 24pt;
}
.back_btn {
height: 24pt;
margin-right: 2px;
}
.btn-toggle-zoom {
height: 24pt;
position: relative;
}
.title {
text-align: center;
line-height: 24pt;
font-size: 18pt;
margin: 0;
}
</style>
+28
View File
@@ -0,0 +1,28 @@
import $ from 'jquery';
import 'bootstrap';
import 'select2/dist/js/select2.full.js'
$(function() {
$('#citySelector').select2({
theme: 'bootstrap4',
placeholder: "도시를 선택해 주세요.",
allowClear: false,
language: "ko",
containerCss: {
display: "inline-block !important",
color: 'white !important'
},
containerCssClass: 'simple-select2-align-center bg-secondary text-secondary',
dropdownCssClass: 'simple-select2-align-center bg-secondary text-secondary',
});
$('#citySelector').on('select2:select', function(e){
const data = e.params.data;
if(!data.selected || data.disabled){
return;
}
const $obj = $('#citySelector').parents('form');
$obj.trigger('submit');
console.log($obj);
});
});
+65
View File
@@ -0,0 +1,65 @@
import type { ValidResponse } from "@/util/callSammoAPI";
export type BasicResourceAuctionBidder = {
amount: number;
date: string;
generalID: number;
generalName: string;
};
export type BasicResourceAuctionInfo = {
id: number;
type: "buyRice" | "sellRice";
hostGeneralID: number;
hostName: string;
openDate: string;
closeDate: string;
amount: number;
startBidAmount: number;
finishBidAmount: number;
highestBid?: BasicResourceAuctionBidder;
};
export type UniqueItemAuctionBidder = {
generalName: string;
amount: number;
isCallerHighestBidder: boolean;
date: string;
};
export type UniqueItemAuctionInfo = {
id: number;
finished: boolean;
title: string;
target: string;
isCallerHost: boolean;
hostName: string;
closeDate: string;
remainCloseDateExtensionCnt: number;
availableLatestBidCloseDate: string;
};
export type ActiveResourceAuctionList = ValidResponse & {
buyRice: BasicResourceAuctionInfo[];
sellRice: BasicResourceAuctionInfo[];
recentLogs: string[];
generalID: number;
};
export type UniqueItemAuctionList = ValidResponse & {
list: (UniqueItemAuctionInfo & {
highestBid: UniqueItemAuctionBidder;
})[];
obfuscatedName: string;
};
export type UniqueItemAuctionDetail = ValidResponse & {
auction: UniqueItemAuctionInfo;
bidList: UniqueItemAuctionBidder[];
obfuscatedName: string;
remainPoint: number;
};
export type OpenAuctionResponse = ValidResponse & {
auctionID: number;
};
+38
View File
@@ -0,0 +1,38 @@
import type { ValidResponse } from "@/defs";
export type BettingListResponse = ValidResponse & {
bettingList: Record<number, Omit<BettingInfo & { totalAmount: number }, "candidates">>;
year: number;
month: number;
};
export type BettingDetailResponse = ValidResponse & {
bettingInfo: BettingInfo;
bettingDetail: [string, number][];
myBetting: [string, number][];
remainPoint: number;
year: number;
month: number;
};
export type BettingInfo = {
id: number;
type: 'nationBetting',
name: string;
finished: boolean;
selectCnt: number;
isExclusive?: boolean;
reqInheritancePoint: boolean;
openYearMonth: number;
closeYearMonth: number;
candidates: Record<string, SelectItem>;
winner?: number[];
}
export type SelectItem = {
title: string;
info?: string;
isHtml?: boolean;
aux?: Record<string, unknown>;
}
+22
View File
@@ -0,0 +1,22 @@
import type { TurnObj } from "@/defs";
export type ReservedCommandResponse = {
result: true;
turnTime: string;
turnTerm: number;
year: number;
month: number;
date: string;
turn: TurnObj[];
autorun_limit: null | number;
};
export type ReserveCommandResponse = {
result: true,
brief: string,
}
export type ReserveBulkCommandResponse = {
result: true,
briefList: string[],
}
+21
View File
@@ -0,0 +1,21 @@
export type JoinArgs = {
name: string;
leadership: number;
strength: number;
intel: number;
pic: boolean;
character: string;
inheritSpecial?: string;
inheritTurntime?: number;
inheritCity?: number;
inheritBonusStat?: [number, number, number];
};
export type GeneralLogType = 'generalHistory'|'generalAction'|'battleResult'|'battleDetail';
export type GetGeneralLogResponse = {
result: true,
reqType: GeneralLogType,
generalID: number,
log: Record<string, string>
}
+79
View File
@@ -0,0 +1,79 @@
import type { CityID, CrewTypeID, GameCityDefault, GameConstType, GameUnitType, GameIActionKey, GameIActionCategory, GameIActionInfo } from "@/defs/GameObj";
import type { diplomacyState, MapResult, SimpleNationObj } from "@/defs";
export interface GetConstResponse {
result: true;
cacheKey: string;
data: {
gameConst: GameConstType;
gameUnitConst: Record<CrewTypeID, GameUnitType>;
cityConst: Record<CityID, GameCityDefault>;
cityConstMap: {
region: Record<number | string, string | number>;
level: Record<number | string, string | number>; //defs.CityLevelText
};
iActionInfo: Record<
GameIActionCategory,
Record<
GameIActionKey,
GameIActionInfo
>
>;
iActionKeyMap: {
availableNationType: "nationType";
neutralNationType: "nationType";
defaultSpecialDomestic: "specialDomestic";
availableSpecialDomestic: "specialDomestic";
optionalSpecialDomestic: "specialDomestic";
defaultSpecialWar: "specialWar";
availableSpecialWar: "specialWar";
optionalSpecialWar: "specialWar";
neutralPersonality: "personality";
availablePersonality: "personality";
optionalPersonality: "personality";
allItems: "item";
};
};
}
export type HistoryObj = {
server_id: string,
year: number;
month: number;
map: MapResult,
global_history: string[],
global_action: string[],
nations: {
capital: number,
cities: string[],
color: string,
gennum: number,
level: number,
name: string,
nation: number,
power: number,
type: GameIActionKey
}[],
}
export type GetHistoryResponse = {
result: true;
data: HistoryObj & {
hash: string;
//no: number,
};
}
export type GetCurrentHistoryResponse = {
result: true;
data: HistoryObj;
}
export type GetDiplomacyResponse = {
result: true;
nations: SimpleNationObj[];
conflict: [number, Record<number, number>][];
diplomacyList: Record<number, Record<number, diplomacyState>>;
myNationID: number;
}
+24
View File
@@ -0,0 +1,24 @@
import type { ValidResponse } from "@/util/callSammoAPI";
export type inheritBuffType =
| "warAvoidRatio"
| "warCriticalRatio"
| "warMagicTrialProb"
| "domesticSuccessProb"
| "domesticFailProb"
| "warAvoidRatioOppose"
| "warCriticalRatioOppose"
| "warMagicTrialProbOppose";
export type InheritPointLogItem = {
id: number;
server_id: string;
year: number;
month: number;
date: string;
text: string;
};
export type InheritLogResponse = ValidResponse & {
log: InheritPointLogItem[];
};
+40
View File
@@ -0,0 +1,40 @@
export type LoginResponse = {
result: true,
nextToken: [number, string] | undefined,
}
export type LoginFailed = {
result: false,
reqOTP: boolean,
reason: string,
}
export type LoginResponseWithKakao = LoginResponse | LoginFailed;
export type OTPResponse = {
result: true,
validUntil: string,
} | {
result: false,
reset: boolean,
reason: string,
}
export type AutoLoginNonceResponse = {
result: true,
loginNonce: string,
};
export type AutoLoginResponse = {
result: true,
nextToken: [number, string] | undefined,
}
export type AutoLoginFailed = {
result: false,
silent: boolean,
reason: string,
}
+5
View File
@@ -0,0 +1,5 @@
import type { ValidResponse } from "@/defs";
export type UploadImageResponse = ValidResponse & {
path: string;
}
+129
View File
@@ -0,0 +1,129 @@
import type { ValuesOf, TurnObj } from "@/defs";
import type { GameObjClassKey } from "@/defs/GameObj";
import type { ValidResponse } from "@/SammoAPI";
export type SetBlockWarResponse = ValidResponse & {
availableCnt: number;
};
export type GeneralListItemP0 = {
no: number,
name: string,
nation: number,
npc: number,
injury: number,
leadership: number,
strength: number,
intel: number,
explevel: number,
dedlevel: number,
gold: number,
rice: number,
killturn: number,
picture: string,
imgsvr: 0 | 1,
age: number,
specialDomestic: GameObjClassKey,
specialWar: GameObjClassKey,
personal: GameObjClassKey,
belong: number,
connect: number,
officerLevel: number, //권한에따라 태수,군사,시종 노출 여부가 다름
officerLevelText: string,
lbonus: number,
ownerName: string | null, //NPC 출력용에 따라 결과가 다름
honorText: string,
dedLevelText: string,
bill: number,
reservedCommand: TurnObj[] | null,
autorun_limit: number,
}
export type GeneralListItemP1 = {
con: number,
specage: number,
specage2: number,
leadership_exp: number,
strength_exp: number,
intel_exp: number,
dex1: number,
dex2: number,
dex3: number,
dex4: number,
dex5: number,
city: number,
experience: number,
dedication: number,
officer_level: number,
officer_city: number,
defence_train: number,
troop: number,
crewtype: GameObjClassKey,
crew: number,
train: number,
atmos: number,
turntime: string,
recent_war: string,
horse: GameObjClassKey,
weapon: GameObjClassKey,
book: GameObjClassKey,
item: GameObjClassKey,
warnum: number,
killnum: number,
deathnum: number,
killcrew: number,
deathcrew: number,
firenum: number,
} & GeneralListItemP0;
export type GeneralListItemP2 = GeneralListItemP1;
export type RawGeneralListItem = GeneralListItemP0 | GeneralListItemP1 | GeneralListItemP2;
export type GeneralListItem =
(GeneralListItemP0 & { st0: true, st1: false, st2: false, permission: 0 }) |
(GeneralListItemP1 & { st0: true, st1: true, st2: false, permission: 1 }) |
(GeneralListItemP2 & { st0: true, st1: true, st2: true, permission: 2 | 3 | 4 });
type ResponseEnv = {
year: number,
month: number,
turntime: string,
turnterm: number,
killturn: number,
autorun_user?: {
limit_minutes: number,
options: Record<string, number>,
}
}
export type RawGeneralListP0 = ValidResponse & {
permission: 0,
column: (keyof GeneralListItemP0)[],
list: ValuesOf<GeneralListItemP0>[][],
troops?: null,
env: ResponseEnv,
}
export type RawGeneralListP1 = ValidResponse & {
permission: 1,
column: (keyof GeneralListItemP1)[],
list: ValuesOf<GeneralListItemP1>[][],
troops?: null,
env: ResponseEnv,
}
export type RawGeneralListP2 = ValidResponse & {
permission: 2 | 3 | 4,
column: (keyof GeneralListItemP2)[],
list: ValuesOf<GeneralListItemP2>[][],
troops: [number, string][],
env: ResponseEnv,
}
export type GeneralListResponse = RawGeneralListP0 | RawGeneralListP1 | RawGeneralListP2;
+30
View File
@@ -0,0 +1,30 @@
import type { CommandItem, TurnObj } from "@/defs";
export type ChiefResponse = {
result: true;
lastExecute: string;
year: number;
month: number;
turnTerm: number;
date: string;
chiefList: Record<
number,
{
name: string | undefined;
turnTime: string | undefined;
officerLevelText: string;
officerLevel: number;
npcType: number;
turn: TurnObj[];
}
>;
isChief: boolean;
autorun_limit: number;
officerLevel: number;
commandList: {
category: string;
values: CommandItem[];
}[];
mapName: string,
unitSet: string,
};
+32
View File
@@ -0,0 +1,32 @@
import type { ValidResponse } from "@/util/callSammoAPI";
export type VoteInfo = {
id: number;
title: string;
multipleOptions: number;
startDate: string;
endDate?: string;
options: string[];
}
export type VoteComment = {
id: number;
voteID: number;
generalID: number;
nationName: string;
generalName: string;
text: string;
date: string;
}
export type VoteListResult = ValidResponse & {
votes: Record<string, VoteInfo>;
}
export type VoteDetailResult = ValidResponse & {
voteInfo: VoteInfo,
votes: [number[], number][],
comments: VoteComment[],
myVote: null|number[],
userCnt: number,
}
+212
View File
@@ -0,0 +1,212 @@
import type { CityLevel, ItemTypeKey } from ".";
type CSSRGBColor = string;
export type GameObjClassKey = string;
export type GameIActionKey = GameObjClassKey;
type SpecialDomesticKey = GameIActionKey;
type SpecialWarKey = GameIActionKey;
type GameItemKey = GameIActionKey;
type MapTypeKey = string;
type UnitSetKey = string;
type RawHTMLString = string;
type NationTypeKey = GameIActionKey;
type GamePersonalityKey = GameIActionKey;
type GeneralCommandName = GameObjClassKey;
type ChiefCommandName = GameObjClassKey;
/** GameConst.php */
export type GameConstType = {
title: string;
banner: RawHTMLString;
mapName: MapTypeKey;
unitSet: UnitSetKey;
develrate: number;
upgradeLimit: number;
dexLimit: number;
defaultAtmosLow: number;
defaultTrainLow: number;
defaultAtmosHigh: number;
defaultTrainHigh: number;
maxAtmosByCommand: number;
maxTrainByCommand: number;
maxAtmosByWar: number;
maxTrainByWar: number;
trainDelta: number;
atmosDelta: number;
atmosSideEffectByTraining: number;
trainSideEffectByAtmosTurn: number;
sabotageDefaultProb: number;
sabotageProbCoefByStat: number;
sabotageDamageMin: number;
sabotageDamageMax: number;
basecolor: CSSRGBColor;
basecolor2: CSSRGBColor;
basecolor3: CSSRGBColor;
basecolor4: CSSRGBColor;
armperphase: number;
basegold: number;
baserice: number;
minNationalGold: number;
minNationalRice: number;
exchangeFee: number;
adultAge: number;
minPushHallAge: number;
maxDedLevel: number;
maxTechLevel: number;
maxBetrayCnt: number;
basePopIncreaseAmount: number;
expandCityPopIncreaseAmount: number;
expandCityDevelIncreaseAmount: number;
expandCityWallIncreaseAmount: number;
expandCityDefaultCost: number;
expandCityCostCoef: number;
minAvailableRecruitPop: number;
initialNationGenLimitForRandInit: number;
initialNationGenLimit: number;
defaultMaxGeneral: number;
defaultMaxNation: number;
defaultMaxGenius: number;
defaultStartYear: number;
joinRuinedNPCProp: number;
defaultGold: number;
defaultRice: number;
coefAidAmount: number;
maxResourceActionAmount: number;
resourceActionAmountGuide: number[];
generalMinimumGold: number;
generalMinimumRice: number;
maxTurn: number;
maxChiefTurn: number;
statGradeLevel: number;
openingPartYear: number;
joinActionLimit: number;
bornMinStatBonus: number;
bornMaxStatBonus: number;
availableNationType: NationTypeKey[];
neutralNationType: NationTypeKey;
defaultSpecialDomestic: SpecialDomesticKey;
availableSpecialDomestic: SpecialDomesticKey[];
optionalSpecialDomestic: SpecialDomesticKey[];
defaultSpecialWar: SpecialWarKey;
availableSpecialWar: SpecialWarKey[];
optionalSpecialWar: SpecialWarKey[];
neutralPersonality: GamePersonalityKey;
availablePersonality: GamePersonalityKey[];
optionalPersonality: GamePersonalityKey[];
maxUniqueItemLimit: [number, number][];
maxAvailableWarSettingCnt: number;
incAvailableWarSettingCnt: number;
minMonthToAllowInheritItem: number;
inheritBornSpecialPoint: number;
inheritBornTurntimePoint: number;
inheritBornCityPoint: number;
inheritBornStatPoint: number;
inheritItemUniqueMinPoint: number;
inheritItemRandomPoint: number;
inheritBuffPoints: number[];
inheritSpecificSpecialPoint: number;
inheritResetAttrPointBase: number[];
allItems: Record<ItemTypeKey, Record<GameItemKey, number>>;
availableGeneralCommand: Record<string, GeneralCommandName[]>;
availableChiefCommand: Record<string, ChiefCommandName[]>;
retirementYear: number;
targetGeneralPool: GameObjClassKey;
generalPoolAllowOption: string[];
randGenFirstName: string[];
randGenMiddleName: string[];
randGenLastName: string[];
npcBanMessageProb: number;
npcSeizureMessageProb: number;
npcMessageFreqByDay: number;
/**
* Scenario::getGameConf
*/
defaultStatTotal: number;
defaultStatMin: number;
defaultStatMax: number;
defaultStatNPCTotal: number;
defaultStatNPCMax: number;
defaultStatNPCMin: number;
chiefStatMin: number;
};
export type CrewTypeID = number;
export type ArmTypeID = 0 | 1 | 2 | 3 | 4 | 5;
export type MapRegionID = number;
export type CityID = number;
export type GameUnitType = {
id: CrewTypeID;
armType: ArmTypeID;
name: string;
attack: number;
defence: number;
speed: number;
avoid: number;
magicCoef: number;
cost: number;
rice: number;
reqTech: number;
reqCities: CityID[] | null;
reqRegions: MapRegionID[] | null;
reqYear: number;
attackCoef: Record<CrewTypeID | ArmTypeID, number> | null | [];
defenceCoef: Record<CrewTypeID | ArmTypeID, number> | null | [];
info: string | string[];
initSkillTrigger: GameObjClassKey[] | null;
phaseSkillTrigger: GameObjClassKey[] | null;
};
export type GameCityDefault = {
id: CityID;
name: string;
level: CityLevel;
population: number;
agriculture: number;
commerce: number;
security: number;
defence: number;
wall: number;
region: number;
posX: number;
posY: number;
path: Record<CityID, string>;
};
export type GameIActionCategory = "nationType" | "specialDomestic" | "specialWar" | "personality" | "item" | "crewtype";
export type GameIActionInfo = {
value: string;
name: string;
info?: string | null;
}
+635
View File
@@ -0,0 +1,635 @@
import type { ColumnState } from "ag-grid-community";
export type GridDisplaySetting = {
column: ColumnState[];
columnGroup: {
groupId: string;
open: boolean;
}[];
};
export const defaultDisplaySetting: Record<"war" | "normal", GridDisplaySetting> = {
war: {
column: [
{
colId: "icon",
width: 80,
hide: true,
sort: null,
},
{
colId: "name",
width: 126,
hide: false,
sort: null,
},
{
colId: "stat_1",
width: 88,
hide: false,
sort: null,
},
{
colId: "troop",
width: 90,
hide: false,
sort: null,
},
{
colId: "leadership",
width: 60,
hide: false,
sort: null,
},
{
colId: "strength",
width: 60,
hide: false,
sort: null,
},
{
colId: "intel",
width: 60,
hide: false,
sort: null,
},
{
colId: "officerLevel",
width: 70,
hide: true,
sort: null,
},
{
colId: "expDedLv_1",
width: 60,
hide: true,
sort: null,
},
{
colId: "explevel",
width: 60,
hide: true,
sort: null,
},
{
colId: "dedlevel",
width: 70,
hide: true,
sort: null,
},
{
colId: "goldRice_1",
width: 80,
hide: false,
sort: null,
},
{
colId: "gold",
width: 70,
hide: false,
sort: null,
},
{
colId: "rice",
width: 70,
hide: false,
sort: null,
},
{
colId: "city",
width: 60,
hide: false,
sort: null,
},
{
colId: "crewtypeAndCrew_1",
width: 80,
hide: false,
sort: null,
},
{
colId: "crewtype",
width: 80,
hide: false,
sort: null,
},
{
colId: "crew",
width: 70,
hide: false,
sort: null,
},
{
colId: "trainAtmos_1",
width: 60,
hide: false,
sort: null,
},
{
colId: "train",
width: 70,
hide: false,
sort: null,
},
{
colId: "atmos",
width: 70,
hide: false,
sort: null,
},
{
colId: "defence_train",
width: 50,
hide: false,
sort: null,
},
{
colId: "specials_1",
width: 80,
hide: true,
sort: null,
},
{
colId: "personal",
width: 60,
hide: true,
sort: null,
},
{
colId: "specialDomestic",
width: 60,
hide: true,
sort: null,
},
{
colId: "specialWar",
width: 60,
hide: true,
sort: null,
},
{
colId: "reservedCommandShort_1",
width: 70,
hide: false,
sort: null,
},
{
colId: "reservedCommand",
width: 120,
hide: false,
sort: null,
},
{
colId: "turntime",
width: 60,
hide: false,
sort: "asc",
sortIndex: 0,
},
{
colId: "recent_war",
width: 60,
hide: true,
sort: null,
},
{
colId: "years_1",
width: 60,
hide: true,
sort: null,
},
{
colId: "age",
width: 60,
hide: true,
sort: null,
},
{
colId: "belong",
width: 60,
hide: true,
sort: null,
},
{
colId: "killturnAndConnect_1",
width: 70,
hide: false,
sort: null,
},
{
colId: "killturn",
width: 70,
hide: false,
sort: null,
},
{
colId: "connect",
width: 70,
hide: true,
sort: null,
},
{
colId: "warResults_1",
hide: true,
sort: null,
},
{
colId: "warnum",
hide: true,
sort: null,
},
{
colId: "killnum",
hide: true,
sort: null,
},
{
colId: "killcrew",
hide: true,
sort: null,
},
],
columnGroup: [
{
groupId: "0",
open: false,
},
{
groupId: "1",
open: false,
},
{
groupId: "stat",
open: false,
},
{
groupId: "2",
open: false,
},
{
groupId: "expDedLv",
open: false,
},
{
groupId: "goldRice",
open: true,
},
{
groupId: "3",
open: false,
},
{
groupId: "4",
open: false,
},
{
groupId: "crewtypeAndCrew",
open: false,
},
{
groupId: "trainAtmos",
open: false,
},
{
groupId: "specials",
open: false,
},
{
groupId: "reservedCommandShort",
open: true,
},
{
groupId: "5",
open: false,
},
{
groupId: "6",
open: false,
},
{
groupId: "years",
open: false,
},
{
groupId: "killturnAndConnect",
open: true,
},
{
groupId: "warResults",
open: false
},
],
},
normal: {
column: [
{
colId: "icon",
width: 80,
hide: false,
pinned: "left",
sort: null,
},
{
colId: "name",
width: 126,
hide: false,
pinned: "left",
sort: null,
},
{
colId: "officerLevel",
width: 70,
hide: false,
sort: null,
},
{
colId: "expDedLv_1",
width: 60,
hide: false,
sort: null,
},
{
colId: "dedlevel",
width: 70,
hide: false,
sort: null,
},
{
colId: "explevel",
width: 60,
hide: false,
sort: null,
},
{
colId: "stat_1",
width: 88,
hide: false,
sort: null,
},
{
colId: "leadership",
width: 60,
hide: false,
sort: null,
},
{
colId: "strength",
width: 60,
hide: false,
sort: null,
},
{
colId: "intel",
width: 60,
hide: false,
sort: null,
},
{
colId: "troop",
width: 90,
hide: true,
sort: null,
},
{
colId: "goldRice_1",
width: 80,
hide: false,
sort: null,
},
{
colId: "gold",
width: 70,
hide: false,
sort: null,
},
{
colId: "rice",
width: 70,
hide: false,
sort: null,
},
{
colId: "city",
width: 60,
hide: true,
sort: null,
},
{
colId: "crewtypeAndCrew_1",
width: 80,
hide: true,
sort: null,
},
{
colId: "crewtype",
width: 80,
hide: true,
sort: null,
},
{
colId: "crew",
width: 70,
hide: true,
sort: null,
},
{
colId: "trainAtmos_1",
width: 60,
hide: true,
sort: null,
},
{
colId: "train",
width: 70,
hide: true,
sort: null,
},
{
colId: "atmos",
width: 70,
hide: true,
sort: null,
},
{
colId: "defence_train",
width: 50,
hide: true,
sort: null,
},
{
colId: "specials_1",
width: 80,
hide: false,
sort: null,
},
{
colId: "personal",
width: 60,
hide: false,
sort: null,
},
{
colId: "specialDomestic",
width: 60,
hide: false,
sort: null,
},
{
colId: "specialWar",
width: 60,
hide: false,
sort: null,
},
{
colId: "reservedCommandShort_1",
width: 70,
hide: true,
sort: null,
},
{
colId: "reservedCommand",
width: 120,
hide: true,
sort: null,
},
{
colId: "turntime",
width: 60,
hide: true,
sort: null,
},
{
colId: "recent_war",
width: 60,
hide: true,
sort: null,
},
{
colId: "years_1",
width: 60,
hide: false,
sort: null,
},
{
colId: "age",
width: 60,
hide: false,
sort: null,
},
{
colId: "belong",
width: 60,
hide: false,
sort: null,
},
{
colId: "killturnAndConnect_1",
width: 70,
hide: false,
sort: null,
},
{
colId: "killturn",
width: 70,
hide: true,
sort: null,
},
{
colId: "connect",
width: 70,
hide: false,
sort: "desc",
sortIndex: 0,
},
{
colId: "warResults_1",
hide: true,
sort: null,
},
{
colId: "warnum",
hide: true,
sort: null,
},
{
colId: "killnum",
hide: true,
sort: null,
},
{
colId: "killcrew",
hide: true,
sort: null,
}
],
columnGroup: [
{
groupId: "0",
open: false
},
{
groupId: "1",
open: false
},
{
groupId: "stat",
open: true
},
{
groupId: "2",
open: false
},
{
groupId: "expDedLv",
open: true
},
{
groupId: "goldRice",
open: true
},
{
groupId: "3",
open: false
},
{
groupId: "4",
open: false
},
{
groupId: "crewtypeAndCrew",
open: false
},
{
groupId: "trainAtmos",
open: false
},
{
groupId: "specials",
open: false
},
{
groupId: "reservedCommandShort",
open: true
},
{
groupId: "5",
open: false
},
{
groupId: "6",
open: false,
},
{
groupId: "years",
open: false
},
{
groupId: "killturnAndConnect",
open: true
},
{
groupId: "warResults",
open: false
},
],
},
};
+267
View File
@@ -0,0 +1,267 @@
import type { Args } from "@/processing/args";
import type { ValidResponse, InvalidResponse } from "@/util/callSammoAPI";
import type { GameIActionKey, GameObjClassKey } from "./GameObj";
export type { ValidResponse, InvalidResponse };
export type BasicGeneralListResponse = {
result: true,
nationID: number,
generalID: number,
column: ['no', 'name', 'npc'],
list: Record<string, [number, string, number][]>,
nation: Record<string, {
nation: number,
name: string,
color: string,
}>
}
export type PublicGeneralItem = {
no: number,
picture: string,
imgsvr: 0 | 1,
npc: number,
age: number,
nation: string,
specialDomestic: GameObjClassKey,
specialWar: GameObjClassKey,
personal: GameObjClassKey,
name: string,
ownerName: string | null,
injury: number,
leadership: number,
lbonus: number,
strength: number,
intel: number,
explevel: number,
experienceStr: string,
dedicationStr: string,
officerLevelStr: string,
killturn: number,
connect: number,
}
export type GeneralListResponse = {
result: true,
list: [
PublicGeneralItem['no'],
PublicGeneralItem['picture'], PublicGeneralItem['imgsvr'],
PublicGeneralItem['npc'], PublicGeneralItem['age'], PublicGeneralItem['nation'],
PublicGeneralItem['specialDomestic'], PublicGeneralItem['specialWar'], PublicGeneralItem['personal'],
PublicGeneralItem['name'], PublicGeneralItem['ownerName'],
PublicGeneralItem['injury'],
PublicGeneralItem['leadership'], PublicGeneralItem['lbonus'], PublicGeneralItem['strength'], PublicGeneralItem['intel'],
PublicGeneralItem['explevel'],
PublicGeneralItem['experienceStr'], PublicGeneralItem['dedicationStr'], PublicGeneralItem['officerLevelStr'],
PublicGeneralItem['killturn'], PublicGeneralItem['connect']
][],
token?: Record<number, number>,
}
export type NationLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
export const NationLevelText: Record<NationLevel, string> = {
0: '방랑군',
1: '호족',
2: '군벌',
3: '주자사',
4: '주목',
5: '공',
6: '왕',
7: '황제',
}
export type CityLevel = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
export const CityLevelText: Record<CityLevel, string> = {
1: '수',
2: '진',
3: '관',
4: '이',
5: '소',
6: '중',
7: '대',
8: '특'
}
export type NationStaticItem = {
nation: number,
name: string,
color: string,
type: string,
level: NationLevel,
capital: number,
gennum: number,
power: number,
}
export type NPCGeneralActions =
'NPC사망대비' |
'귀환' |
'금쌀구매' |
'출병' |
'긴급내정' |
'전투준비' |
'전방워프' |
//'NPC증여'|
'NPC헌납' |
'징병' |
'후방워프' |
'전쟁내정' |
'소집해제' |
'일반내정' |
'내정워프';
export type NPCChiefActions =
'불가침제의' |
'선전포고' |
'천도' |
'유저장긴급포상' |
'부대전방발령' |
'유저장구출발령' |
'유저장후방발령' |
'부대유저장후방발령' |
'유저장전방발령' |
'유저장포상' |
//'유저장몰수'|
'부대구출발령' |
'부대후방발령' |
'NPC긴급포상' |
'NPC구출발령' |
'NPC후방발령' |
'NPC포상' |
'NPC전방발령' |
'유저장내정발령' |
'NPC내정발령' |
'NPC몰수';
export type NationPolicy = {
reqNationGold: number,
reqNationRice: number,
CombatForce: Record<number, number[]>,
SupportForce: number[],
DevelopForce: number[],
reqHumanWarUrgentGold: number,
reqHumanWarUrgentRice: number,
reqHumanWarRecommandGold: number,
reqHumanWarRecommandRice: number,
reqHumanDevelGold: number,
reqHumanDevelRice: number,
reqNPCWarGold: number,
reqNPCWarRice: number,
reqNPCDevelGold: number,
reqNPCDevelRice: number,
minimumResourceActionAmount: number,
maximumResourceActionAmount: number,
minNPCWarLeadership: number,
minWarCrew: number,
minNPCRecruitCityPopulation: number,
safeRecruitCityPopulationRatio: number,
properWarTrainAtmos: number,
}
export type ItemTypeKey = 'horse' | 'weapon' | 'book' | 'item';
export const ItemTypeNameMap: Record<ItemTypeKey, string> = {
horse: '명마',
weapon: '무기',
book: '서적',
item: '도구',
}
export declare type Colors = 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'dark' | 'light';
export type IDItem<T> = {
id: T;
};
export type ToastType = {
content: {
title?: string,
body?: string,
},
options?: {
variant?: Colors,
delay?: number,
}
}
export const keyScreenMode = 'sam.screenMode';
export type ScreenModeType = 'auto' | '500px' | '1000px';
export declare type ValuesOf<T> = T[keyof T];
export const NoneValue = 'None' as const;
export type Optional<Type> = {
[Property in keyof Type]+?: Type[Property];
};
export type OptionalFull<Type> = {
[Property in keyof Type]: Type[Property] | undefined;
};
export type TurnObj = {
action: string;
brief: string;
arg: Args;
};
export type CommandItem = {
value: string;
title: string;
info: string,
compensation: number;
simpleName: string;
possible: boolean;
reqArg: boolean;
searchText?: string;
};
type diplomacyInfo = {
name: string,
color?: string,
}
export type diplomacyState = 0 | 1 | 2 | 7;
export const diplomacyStateInfo: Record<diplomacyState, diplomacyInfo> = {
0: { name: '교전', color: 'red' },
1: { name: '선포중', color: 'magenta' },
2: { name: '통상' },
7: { name: '불가침', color: 'green' },
}
export const CURRENT_MAP_VERSION = 0 as const;
//Map
type MapCityCompact = [number, number, number, number, number, number];
type MapNationCompact = [number, string, string, number];
export type MapResult = {
result: true,
version?: typeof CURRENT_MAP_VERSION,
startYear: number,
year: number,
month: number,
cityList: MapCityCompact[],
nationList: MapNationCompact[],
spyList: Record<number, number>,
shownByGeneralList: number[],
myCity?: number,
myNation?: number,
}
export type CachedMapResult = MapResult & {
theme?: string,
history?: string[],
}
export type SimpleNationObj = {
capital: number,
cities: string[],
color: string,
gennum: number,
level: number,
name: string,
nation: number,
power: number,
type: GameIActionKey
}
+493
View File
@@ -0,0 +1,493 @@
import $ from 'jquery';
import { unwrap_any } from '@util/unwrap_any';
import axios from 'axios';
import { isBrightColor } from "@util/isBrightColor";
import { setAxiosXMLHttpRequest } from '@util/setAxiosXMLHttpRequest';
import { isString } from 'lodash';
import { convertFormData } from '@util/convertFormData';
import type { InvalidResponse, NationStaticItem } from '@/defs';
import { escapeHtml } from '@/legacy/escapeHtml';
import { nl2br } from '@util/nl2br';
import { unwrap } from '@util/unwrap';
import 'bootstrap';
import 'select2/dist/js/select2.full.js'
import type { LoadingData } from 'select2';
declare const permissionLevel: number;
let myNationID: number | undefined;
type LetterNationTarget = {
nationID: number,
nationName: string,
nationColor: string,
}
type LetterFullTarget = LetterNationTarget & {
nationID: number,
nationName: string,
nationColor: string,
generalName: string,
generalIcon: string,
}
type LetterState = 'proposed' | 'activated' | 'cancelled' | 'replaced';
type LetterItem = {
no: number,
src: LetterFullTarget,
dest: LetterNationTarget | LetterFullTarget,
prev_no: number | null,
state: LetterState,
state_opt: 'try_destroy_src' | 'try_destroy_dest' | null,
brief: string,
detail: string,
date: string,
}
type LetterResponse = {
result: true,
nations: Record<number, NationStaticItem>,
letters: Record<number, LetterItem>,
myNationID: number,
}
const stateText: Record<LetterState, string> = {
'proposed': '제안됨',
'activated': '승인됨',
'cancelled': '거부됨',
'replaced': '대체됨',
};
const stateOptionText: Record<NonNullable<LetterItem['state_opt']>, string> = {
'try_destroy_src': '송신측의 파기 요청',
'try_destroy_dest': '수신측의 파기 요청',
}
async function submitLetter(e: JQuery.Event): Promise<void> {
e.preventDefault();
const $brief = $('#inputBrief');
const $detail = $('#inputDetail');
const $prevNo = $('#inputPrevNo');
const $destNation = $('#inputDestNation');
const brief = $.trim(unwrap_any<string>($brief.val()));
const detail = $.trim(unwrap_any<string>($detail.val()));
let prevNo = $prevNo.val() as number | undefined;
const destNation = unwrap_any<number>($destNation.val());
if (prevNo !== undefined && prevNo < 1) {
prevNo = undefined;
}
console.log(brief);
if (!brief) {
return;
}
$brief.val('');
$detail.val('');
let result: InvalidResponse;
try {
const response = await axios({
url: 'j_diplomacy_send_letter.php',
responseType: 'json',
method: 'post',
data: convertFormData({
brief: brief,
detail: detail,
destNation: destNation,
prevNo: prevNo ?? null,
})
});
result = response.data;
if (!result.result) {
throw result.reason;
}
}
catch (e) {
console.error(e);
alert(`외교 서신을 보내는데 실패했습니다: ${e}`);
return;
}
alert('전송했습니다.');
location.reload();
}
async function repondLetter(letterNo: number, isAgree: boolean, reason: string | null): Promise<void> {
let result: InvalidResponse;
try {
const response = await axios({
url: 'j_diplomacy_respond_letter.php',
responseType: 'json',
method: 'post',
data: convertFormData({
letterNo: letterNo,
isAgree: isAgree,
reason: reason,
})
});
result = response.data;
if (!result.result) {
throw result.reason;
}
}
catch (e) {
console.error(e);
alert(`응답을 실패했습니다: ${e}`);
return;
}
if (isAgree) {
alert('승인했습니다.');
}
else {
alert('거부했습니다.');
}
location.reload();
}
async function rollbackLetter(letterNo: number) {
let result: InvalidResponse;
try {
const response = await axios({
url: 'j_diplomacy_rollback_letter.php',
responseType: 'json',
method: 'post',
data: convertFormData({
letterNo: letterNo,
})
});
result = response.data;
if (!result.result) {
throw result.reason;
}
}
catch (e) {
console.error(e);
alert(`회수를 실패했습니다: ${e}`);
return;
}
alert('회수 했습니다.');
location.reload();
}
async function destroyLetter(letterNo: number) {
let result: InvalidResponse | {
result: true,
state: LetterState
};
try {
const response = await axios({
url: 'j_diplomacy_destroy_letter.php',
responseType: 'json',
method: 'post',
data: convertFormData({
letterNo: letterNo,
})
});
result = response.data;
if (!result.result) {
throw result.reason;
}
}
catch (e) {
console.error(e);
alert(`회수를 실패했습니다: ${e}`);
return;
}
if (result.state == 'activated') {
alert('파기를 요청 했습니다.');
}
else {
alert('파기 되었습니다.');
}
location.reload();
}
function drawLetter(letterObj: LetterItem) {
if (letterObj.state == 'cancelled') {
//TODO: 취소되거나, 대체된 문서도 보여줄 방법을 찾아볼 것
return;
}
console.log(letterObj);
const $letterFrame = $('#letterTemplate > .letterFrame');
const srcColorFormat = {
'background-color': letterObj.src.nationColor,
'color': isBrightColor(letterObj.src.nationColor) ? '#000000' : '#ffffff'
};
const destColorFormat = {
'background-color': letterObj.dest.nationColor,
'color': isBrightColor(letterObj.dest.nationColor) ? '#000000' : '#ffffff'
};
const targetNation = letterObj.src.nationID == myNationID ? letterObj.dest : letterObj.src;
const targetColor = letterObj.src.nationID == myNationID ? destColorFormat : srcColorFormat;
const $letter = $letterFrame.clone();
if (letterObj.state == 'replaced') {
$letter.hide();
}
$letter.addClass('letterObj')
.data('no', letterObj.no)
.data('brief', letterObj.brief)
.data('detail', letterObj.detail)
.attr('id', 'letter_' + letterObj.no);
$letter.find('.letterHeader').css(targetColor);
$letter.find('.letterNationName').text(targetNation.nationName);
$letter.find('.letterDate').text(letterObj.date);
$letter.find('.letterNo').text('#' + letterObj.no);
$letter.find('.letterStatus').text(stateText[letterObj.state]);
if (letterObj.state_opt !== null) {
$letter.find('.letterStatusOpt').text(`(${unwrap(stateOptionText[letterObj.state_opt])})`);
}
if (letterObj.prev_no !== null) {
const $showPrev = $(`<a href="#">#${letterObj.prev_no}</a>`);
$showPrev.click(function () {
$('#letter_' + letterObj.prev_no).toggle();
})
$letter.find('.letterPrevNo').empty().append($showPrev);
}
else {
$letter.find('.letterPrevNo').text('신규');
}
$letter.find('.letterBrief').html(nl2br(escapeHtml(letterObj.brief)));
$letter.find('.letterDetail').html(nl2br(escapeHtml(letterObj.detail)));
$letter.find('.letterSrc .signerImg img.generalIcon').attr('src', letterObj.src.generalIcon);
$letter.find('.letterSrc .signerNation').text(letterObj.src.nationName).css(srcColorFormat);
$letter.find('.letterSrc .signerName').text(letterObj.src.generalName).css(srcColorFormat);
if ('generalName' in letterObj.dest) {
$letter.find('.letterDest .signerImg img.generalIcon').attr('src', letterObj.dest.generalIcon);
$letter.find('.letterDest .signerNation').text(letterObj.dest.nationName).css(destColorFormat);
$letter.find('.letterDest .signerName').text(letterObj.dest.generalName).css(destColorFormat);
}
if (letterObj.state == 'proposed' && letterObj.src.nationID != myNationID) {
$letter.find('.btnAgree').show().click(async function (e) {
e.preventDefault();
if (!confirm('승인하시겠습니까?')) {
return;
}
await repondLetter(letterObj.no, true, null);
});
$letter.find('.btnDisagree').show().click(async function (e) {
e.preventDefault();
let reason = prompt('거부하시겠습니까? (이유 [최대 50자])');
if (reason === null) {
return;
}
reason = reason.substr(0, 50);
await repondLetter(letterObj.no, false, reason);
});
}
if (letterObj.state == 'proposed' && letterObj.src.nationID == myNationID) {
$letter.find('.btnRollback').show().click(async function (e) {
e.preventDefault();
if (!confirm('회수하시겠습니까?')) {
return false;
}
await rollbackLetter(letterObj.no);
});
}
if (letterObj.state == 'activated') {
const $btnDestroy = $letter.find('.btnDestroy');
if ((letterObj.src.nationID == myNationID && letterObj.state_opt == 'try_destroy_src') ||
(letterObj.dest.nationID == myNationID && letterObj.state_opt == 'try_destroy_dest')) {
$btnDestroy.show().prop('disabled', true);
}
else {
$btnDestroy.show().click(async function (e) {
e.preventDefault();
if (!confirm('본 문서를 파기하겠습니까? (상호 동의 필요)')) {
return false;
}
await destroyLetter(letterObj.no);
})
}
}
$letter.find('.btnRenew').click(function () {
const $inputPrevNo = $('#inputPrevNo');
$inputPrevNo.val(letterObj.no);
$inputPrevNo.trigger('change');
})
$('#letters').prepend($letter);
}
function initNewLetterForm(lettersObj: LetterResponse) {
console.log(lettersObj);
const nationList: (NationStaticItem & { id: number, text: string })[] = [];
for (const nation of Object.values(lettersObj.nations)) {
nationList.push({
...nation,
id: nation.nation,
text: nation.name,
});
}
const $destNation = $('#inputDestNation').select2({
theme: 'bootstrap4',
placeholder: "",
language: "ko",
width: '300px',
containerCss: {
display: "inline-block !important;",
color: 'white !important'
},
data: nationList,
templateResult: colorNation,
containerCssClass: 'simple-select2-align-center bg-secondary text-secondary',
dropdownCssClass: 'simple-select2-align-center bg-secondary text-secondary',
});
const prevNoList: {
id: number,
text: string,
nation: null | number,
}[] = [{
id: 0,
text: '-새 문서-',
nation: null,
}];
for(const letterObj of Object.values(lettersObj.letters)){
if (letterObj.state == 'replaced' || letterObj.state == 'cancelled') {
continue;
}
const targetNation = letterObj.src.nationID == myNationID ? letterObj.dest : letterObj.src;
prevNoList.push({
id: letterObj.no,
text: `#${letterObj.no} <${targetNation.nationName}>`,
nation: targetNation.nationID
});
}
const $inputPrevNo = $('#inputPrevNo').select2({
theme: 'bootstrap4',
placeholder: "",
language: "ko",
width: '300px',
containerCss: {
display: "inline-block !important;",
color: 'white !important'
},
data: prevNoList,
containerCssClass: 'simple-select2-align-center bg-secondary text-secondary',
dropdownCssClass: 'simple-select2-align-center bg-secondary text-secondary',
});
$inputPrevNo.on('change', function () {
const data = $inputPrevNo.select2('data')[0] as unknown as typeof prevNoList[0];
console.log(data);
if (data.nation == null) {
$destNation.prop("disabled", false);
}
else {
$destNation.val(data.nation).prop("disabled", true);
const $targetLetter = $('#letter_' + data.id);
resizeTextarea($('#inputBrief').val($targetLetter.data('brief')));
resizeTextarea($('#inputDetail').val($targetLetter.data('detail')));
}
});
$('#btnSend').click(submitLetter);
$('#newLetter').show();
}
function drawLetters(lettersObj: LetterResponse) {
myNationID = lettersObj.myNationID;
if (permissionLevel == 4) {
initNewLetterForm(lettersObj);
$('.letterActionPlate').show();
}
$('.letterObj').detach();//첫 버전이니까 일괄 삭제 일괄 로드
for (const letter of Object.values(lettersObj.letters)) {
drawLetter(letter);
}
}
async function loadLetters(): Promise<LetterResponse> {
const response = await axios({
url: 'j_diplomacy_get_letter.php',
responseType: 'json',
method: 'post',
data: convertFormData({
})
});
const result: LetterResponse|InvalidResponse = response.data;
if(!result.result){
throw result.reason;
}
return result;
}
function colorNation(nationInfo: { id: number, text: string, color?: string } | LoadingData) {
if (!('color' in nationInfo)) {
return nationInfo.text;
}
if (!nationInfo.color) {
return nationInfo.text;
}
const fgColor = isBrightColor(nationInfo.color) ? '#000000' : '#ffffff';
const $nationForm = $('<div>' + nationInfo.text + '</div>').css({
'color': fgColor,
'background-color': nationInfo.color
});
return $nationForm;
}
function resizeTextarea($obj: JQuery<HTMLElement>) {
$obj.height(1).height($obj.prop('scrollHeight') + 12);
}
$(async function () {
setAxiosXMLHttpRequest();
$('textarea.autosize').on('keydown keyup', function () {
resizeTextarea($(this));
})
try {
const letters = await loadLetters();
drawLetters(letters);
}
catch (e) {
console.error(e);
if (isString(e)) {
alert(e);
}
else {
alert(`실패했습니다.`);
}
return;
}
});
+97
View File
@@ -0,0 +1,97 @@
import type { Directive, DirectiveBinding } from 'vue'
import Tooltip from 'bootstrap/js/dist/tooltip'
function resolveTrigger(modifiers: DirectiveBinding['modifiers']): Tooltip.Options['trigger'] {
if (modifiers.manual) {
return 'manual'
}
const trigger: string[] = []
if (modifiers.click) {
trigger.push('click')
}
if (modifiers.hover) {
trigger.push('hover')
}
if (modifiers.focus) {
trigger.push('focus')
}
if (trigger.length > 0) {
return trigger.join(' ') as Tooltip.Options['trigger']
}
return 'hover focus'
}
function resolvePlacement(modifiers: DirectiveBinding['modifiers']): Tooltip.Options['placement'] {
if (modifiers.left) {
return 'left'
}
if (modifiers.right) {
return 'right'
}
if (modifiers.bottom) {
return 'bottom'
}
return 'top'
}
function resolveDelay(values: TooltipOptions): Tooltip.Options['delay'] {
if (values?.delay) {
return values.delay
}
return 0
}
type TooltipOptions = {
delay?: number,
class?: string
} | undefined;
const vMyTooltip: Directive<HTMLElement, TooltipOptions> = {
beforeMount(el, binding) {
el.setAttribute('data-bs-toogle', 'tooltip')
const isHtml = /<("[^"]*"|'[^']*'|[^'">])*>/.test(el.title)
const trigger = resolveTrigger(binding.modifiers)
const placement = resolvePlacement(binding.modifiers)
const delay = resolveDelay(binding.value)
const options: Partial<Tooltip.Options> = {
trigger,
placement,
delay,
html: isHtml,
}
const customClass = binding.value?.class;
if (customClass) {
options.customClass = customClass;
}
new Tooltip(el, options)
},
updated(el) {
const title = el.getAttribute('title')
if (title !== '') {
const instance = Tooltip.getInstance(el)
instance?.hide()
el.setAttribute('data-bs-original-title', title || '')
el.setAttribute('title', '')
}
},
unmounted(el) {
const instance = Tooltip.getInstance(el)
instance?.dispose()
},
}
export default vMyTooltip
+677
View File
@@ -0,0 +1,677 @@
import $ from 'jquery';
import { unwrap_any } from '@util/unwrap_any';
type UserItem = {
val: string,
name: string,
city: string,
태수: boolean,
군사: boolean,
종사: boolean,
is수뇌: boolean,
$city: CityItem,
$user: JQuery<HTMLElement>,
}
type CityLevel = '특' | '대' | '중' | '소' | '이' | '진' | '관' | '수'
type OfficerItem = {
preserved: boolean,
$obj: JQuery<HTMLElement>
}
type CityItem = {
지역: string,
규모: CityLevel,
이름: string,
val: string,
users: JQuery<HTMLElement>,
userCnt: number,
obj: JQuery<HTMLElement>,
태수: OfficerItem,
군사: OfficerItem,
종사: OfficerItem,
warn주민: boolean,
warn농업: boolean,
warn상업: boolean,
warn치안: boolean,
warn수비: boolean,
warn성벽: boolean,
$주민: JQuery<HTMLElement>,
$인구율: JQuery<HTMLElement>,
$농업: JQuery<HTMLElement>,
$상업: JQuery<HTMLElement>,
$치안: JQuery<HTMLElement>,
$수비: JQuery<HTMLElement>,
$성벽: JQuery<HTMLElement>,
주민: number,
농업: number,
상업: number,
치안: number,
수비: number,
성벽: number,
max주민: number,
max농업: number,
max상업: number,
max치안: number,
max수비: number,
max성벽: number,
remain주민: number,
remain농업: number,
remain상업: number,
remain치안: number,
remain수비: number,
remain성벽: number,
};
type OfficerSelector = '태수' | '군사' | '종사';
const cityList: Record<string, CityItem> = {};
const userList: Record<string, UserItem> = {};
$(function () {
let basicPath = document.location.pathname;
basicPath = basicPath.substring(0, basicPath.lastIndexOf('/')) + '/';
const mergeSort = function <T>(arr: T[], cmpFunc: (lhs: T, rhs: T) => number) {
const merge = function (left: T[], right: T[]): T[] {
const retVal = [];
let leftIdx = 0;
let rightIdx = 0;
while (leftIdx < left.length && rightIdx < right.length) {
const cmpVal = cmpFunc(left[leftIdx], right[rightIdx]);
if (cmpVal <= 0) {
retVal.push(left[leftIdx]);
leftIdx++;
}
else {
retVal.push(right[rightIdx]);
rightIdx++;
}
}
return retVal.concat(left.slice(leftIdx)).concat(right.slice(rightIdx));
};
const _mergeSort = function (arr: T[]): T[] {
if (arr.length < 2) {
return arr;
}
const middle = Math.floor(arr.length / 2);
const left = arr.slice(0, middle);
const right = arr.slice(middle);
return merge(_mergeSort(left), _mergeSort(right));
};
return _mergeSort(arr);
};
const loadDuty = function () {
try {
$('.for_duty').remove();
void $.get(basicPath + 'b_myBossInfo.php', function (rawData) {
const $html = $(rawData);
const $tmpTable = $html.filter('#officer_list').eq(0);
const $selects = $tmpTable.find("select");
if ($selects.length == 0) {
alert("수뇌가 아닙니다!");
return false;
}
const setUserAvailable = function ($userList: JQuery<HTMLElement>, typeName: OfficerSelector) {
$userList.each(function () {
const $this = $(this);
const val = unwrap_any<string>($this.val());
let name = $.trim($this.text());
for (let i = name.length - 1; i > 0; i--) {
if (name[i] == '【') {
name = $.trim(name.substr(0, i));
break;
}
}
if (val == '0') {
return;
}
if (userList[name] !== undefined) {
userList[name].val = val;
userList[name][typeName] = true;
}
});
};
const setCityAvailiable = function ($cityList: JQuery<HTMLElement>, typeName: OfficerSelector) {
$cityList.each(function () {
const $this = $(this);
const val = $.trim(unwrap_any<string>($this.val()));
const name = $.trim($this.text());
cityList[name].val = val;
cityList[name][typeName].preserved = true;
});
}
for (const cityInfo of Object.values(cityList)) {
cityInfo..preserved = false;
cityInfo..preserved = false;
cityInfo..preserved = false;
}
$.each(userList, function (idx, userInfo) {
userInfo. = false;
userInfo. = false;
userInfo. = false;
});
setUserAvailable($selects.eq(1).find("option"), "태수");
setUserAvailable($selects.eq(3).find("option"), "군사");
setUserAvailable($selects.eq(5).find("option"), "종사");
setCityAvailiable($selects.eq(0).find("option"), "태수");
setCityAvailiable($selects.eq(2).find("option"), "군사");
setCityAvailiable($selects.eq(4).find("option"), "종사");
$.each(cityList, function (idx, cityInfo) {
//console.log(cityInfo.users.children());
cityInfo.users.children().each(function () {
//console.log(this);
const $this = $(this);
const username = $this.data('username');
const userInfo = userList[username];
if (!userInfo) {
return;
}
if (userInfo.val == '-1') {
return;
}
const $name = $this.find('.nameplate');
$name.append('<br class="for_duty">');
const addBtn = function ($name: JQuery<HTMLElement>, cityInfo: CityItem, userInfo: UserItem, level: number, typeName: OfficerSelector) {
const enabled = cityInfo[typeName] && userInfo[typeName];
const cityVal = cityInfo.val;
const $btn = $('<button type="button">' + typeName.substr(0, 1) + '</button>');
$btn.addClass(`mode_${level}`);
$btn.addClass('for_duty');
if (!enabled) {
$btn.prop('disabled', true);
$btn.css('background', 'transparent');
$btn.css('border', '0');
}
else {
if (userInfo.is수뇌) {
$btn.css('color', 'red');
}
}
$btn.css('padding', '1px 4px');
$btn.css('margin', '0');
$btn.click(function () {
if (userInfo.is수뇌) {
if (!confirm('수뇌입니다. 임명할까요?')) {
return false;
}
}
void $.post(basicPath + 'j_myBossInfo.php', {
destCityID: cityVal,
destGeneralID: userInfo.val,
officerLevel: level,
action: '임명'
}, function () {
cityInfo[typeName].preserved = false;
const $target = cityInfo.users.find(`.mode_${level}`);
$target.prop('disabled', true);
$target.css('background', 'transparent');
$target.css('border', '0');
$target.css('color', '');
cityInfo[typeName].$obj.html(userInfo.name);
});
});
//console.log($btn);
$name.append($btn);
};
addBtn($name, cityInfo, userInfo, 4, '태수');
addBtn($name, cityInfo, userInfo, 3, '군사');
addBtn($name, cityInfo, userInfo, 2, '종사');
});
});
});
}
catch (a) {
console.log(a);
}
return false;
};
const loadUser = function () {
$.each(cityList, function (idx, val) {
if (typeof val.users == "undefined") {
val.obj.append('<tr><td colspan="10"><table align="center" class="tb_layout cityUser bg0">' +
'<thead><tr>' +
'<td width="100" align="center" class="bg1">이 름</td><td width="100" align="center" class="bg1">통무지</td><td width="100" align="center" class="bg1">부 대</td><td width="60" align="center" class="bg1">자 금</td>' +
'<td width="60" align="center" class="bg1">군 량</td><td width="30" align="center" class="bg1">守</td><td width="60" align="center" class="bg1">병 종</td>' +
'<td width="60" align="center" class="bg1">병 사</td><td width="50" align="center" class="bg1">훈련</td><td width="50" align="center" class="bg1">사기</td><td width="150" align="center" class="bg1">명 령</td>' +
'<td width="60" align="center" class="bg1">삭턴</td><td width="60" align="center" class="bg1">턴</td>' +
'</tr></thead>' +
'<tbody class="cityUserBody"></tbody></table></td></tr>');
val.users = val.obj.find(".cityUserBody");
}
else {
val.users.html("");
}
});
void $.get(basicPath + 'b_genList.php', function (rawData) {
const $helper = $('#helper_genlist');
$helper.html('').append($.parseHTML(rawData));
const tmpUsers = $('#general_list tbody tr');
tmpUsers.each(function () {
const $this = $(this);
const $city = $this.children('.i_city');
$city.remove();
const cityName = $.trim($city.text());
const $name = $this.children('.i_name');
$name.addClass('nameplate');
const name = $name.find('.t_name').text();
const $work = $this.children('.i_action');
const cityInfo = cityList[cityName];
if (typeof cityInfo == 'undefined') {
return;
}
if (cityInfo.warn주민) $work.html($work.html().split('정착 장려').join('<span style="color:yellow;">정착 장려</span>'));
if (cityInfo.warn농업) $work.html($work.html().split('농지 개간').join('<span style="color:yellow;">농지 개간</span>'));
if (cityInfo.warn상업) $work.html($work.html().split('상업 투자').join('<span style="color:yellow;">상업 투자</span>'));
if (cityInfo.warn치안) $work.html($work.html().split('치안 강화').join('<span style="color:yellow;">치안 강화</span>'));
if (cityInfo.warn수비) $work.html($work.html().split('수비 강화').join('<span style="color:yellow;">수비 강화</span>'));
if (cityInfo.warn성벽) $work.html($work.html().split('성벽 보수').join('<span style="color:yellow;">성벽 보수</span>'));
const $stat = $this.children('.i_stat');
const stat = $stat.text();
const is수뇌 = stat.indexOf('+') >= 0;
$this.data('username', name);
if (cityList[cityName]..$obj.text() == name) {
cityList[cityName]..$obj.html(`<span style="color:lightgreen">${name}</span>`);
}
if (cityList[cityName]..$obj.text() == name) {
cityList[cityName]..$obj.html(`<span style="color:lightgreen">${name}</span>`);
}
if (cityList[cityName]..$obj.text() == name) {
cityList[cityName]..$obj.html(`<span style="color:lightgreen">${name}</span>`);
}
userList[name] = {
$city: cityInfo,
city: cityName,
$user: $this,
name: name,
val: '-1',
태수: false,
군사: false,
종사: false,
is수뇌: is수뇌
};
if (cityList[cityName]) {
cityList[cityName].users.append($this);
}
});
});
if ($("#loadDutyBtn").length == 0) {
const $onBossList = $('<button id="loadDutyBtn">인사부 연동</button>');
$onBossList.click(function () {
loadDuty();
return false;
});
$('form').append($onBossList);
}
$('#by_users').show();
};
const mainFunc = function () {
//대상 추출
$("form").each(function () {
const $this = $(this);
$this.attr('name', 'p' + $this.attr('name'));
});
$("table").each(function () {
const $this = $(this);
if ($this.attr('class') == 'tb_layout bg2') {
$this.addClass('cityInfo');
}
else {
return;
}
const cityInfo: CityItem = {
: {},
: {},
: {},
} as CityItem;
//이름 추출
{
const titleText = $this.find('tr:eq(0)>td:eq(0)').text();
const loc0 = titleText.indexOf("【");
const loc1 = titleText.indexOf("|");
const loc2 = titleText.indexOf("】");
const cityLoc = $.trim(titleText.substring(loc0 + 1, loc1));
const citySize = $.trim(titleText.substring(loc1 + 1, loc2));
let cityName = $.trim(titleText.substring(loc2 + 1));
cityName = cityName.replace("[", "");
cityName = cityName.replace("]", "");
$this.data('cityname', cityName);
cityInfo. = cityLoc;
cityInfo. = citySize as CityLevel;
cityInfo. = cityName;
cityInfo.val = '-1';
cityInfo..preserved = false;
cityInfo..preserved = false;
cityInfo..preserved = false;
}
//주민, 농상치성수
{
cityInfo.$주민 = $this.find('.pop-value');
cityInfo.$인구율 = $this.find('.pop-prop-value');
cityInfo.$농업 = $this.find('.agri-value');
cityInfo.$상업 = $this.find('.comm-value');
cityInfo.$치안 = $this.find('.secu-value');
cityInfo.$수비 = $this.find('.def-value');
cityInfo.$성벽 = $this.find('.wall-value');
let tmpVal;
tmpVal = cityInfo.$주민.text().split('/');
cityInfo. = parseInt(tmpVal[0]);
cityInfo.max주민 = parseInt(tmpVal[1]);
tmpVal = cityInfo.$농업.text().split('/');
cityInfo. = parseInt(tmpVal[0]);
cityInfo.max농업 = parseInt(tmpVal[1]);
tmpVal = cityInfo.$상업.text().split('/');
cityInfo. = parseInt(tmpVal[0]);
cityInfo.max상업 = parseInt(tmpVal[1]);
tmpVal = cityInfo.$치안.text().split('/');
cityInfo. = parseInt(tmpVal[0]);
cityInfo.max치안 = parseInt(tmpVal[1]);
tmpVal = cityInfo.$수비.text().split('/');
cityInfo. = parseInt(tmpVal[0]);
cityInfo.max수비 = parseInt(tmpVal[1]);
tmpVal = cityInfo.$성벽.text().split('/');
cityInfo. = parseInt(tmpVal[0]);
cityInfo.max성벽 = parseInt(tmpVal[1]);
if (cityInfo. > cityInfo.max주민 * 0.9) {
cityInfo.$주민.css('color', 'lightgreen');
cityInfo.$인구율.css('color', 'lightgreen');
}
else if (cityInfo. > cityInfo.max주민 * 0.7) {
cityInfo.$주민.css('color', 'yellow');
cityInfo.$인구율.css('color', 'yellow');
}
else {
cityInfo.$주민.css('color', 'orangered');
cityInfo.$인구율.css('color', 'orangered');
}
if (cityInfo. > cityInfo.max농업 * 0.8) { cityInfo.$농업.css('color', 'lightgreen'); }
else if (cityInfo. > cityInfo.max농업 * 0.4) { cityInfo.$농업.css('color', 'yellow'); }
else { cityInfo.$농업.css('color', 'orangered'); }
if (cityInfo. > cityInfo.max상업 * 0.8) { cityInfo.$상업.css('color', 'lightgreen'); }
else if (cityInfo. > cityInfo.max상업 * 0.4) { cityInfo.$상업.css('color', 'yellow'); }
else { cityInfo.$상업.css('color', 'orangered'); }
if (cityInfo. > cityInfo.max치안 * 0.8) { cityInfo.$치안.css('color', 'lightgreen'); }
else if (cityInfo. > cityInfo.max치안 * 0.4) { cityInfo.$치안.css('color', 'yellow'); }
else { cityInfo.$치안.css('color', 'orangered'); }
if (cityInfo. > cityInfo.max수비 * 0.6) { cityInfo.$수비.css('color', 'lightgreen'); }
else if (cityInfo. > cityInfo.max수비 * 0.3) { cityInfo.$수비.css('color', 'yellow'); }
else { cityInfo.$수비.css('color', 'orangered'); }
if (cityInfo. > cityInfo.max성벽 * 0.6) { cityInfo.$성벽.css('color', 'lightgreen'); }
else if (cityInfo. > cityInfo.max성벽 * 0.3) { cityInfo.$성벽.css('color', 'yellow'); }
else { cityInfo.$성벽.css('color', 'orangered'); }
cityInfo.remain주민 = cityInfo. - cityInfo.max주민;
cityInfo.remain농업 = cityInfo. - cityInfo.max농업;
cityInfo.remain상업 = cityInfo. - cityInfo.max상업;
cityInfo.remain치안 = cityInfo. - cityInfo.max치안;
cityInfo.remain수비 = cityInfo. - cityInfo.max수비;
cityInfo.remain성벽 = cityInfo. - cityInfo.max성벽;
cityInfo.warn주민 = false;
cityInfo.warn농업 = false;
cityInfo.warn상업 = false;
cityInfo.warn치안 = false;
cityInfo.warn수비 = false;
cityInfo.warn성벽 = false;
if (cityInfo.remain주민 > -10 * 2000) cityInfo.warn주민 = true;
if (cityInfo. > 0.92 * cityInfo.max주민) cityInfo.warn주민 = true;
if (cityInfo.remain농업 > -10 * 100) cityInfo.warn농업 = true;
if (cityInfo.remain상업 > -10 * 100) cityInfo.warn상업 = true;
if (cityInfo.remain치안 > -10 * 100) cityInfo.warn치안 = true;
if (cityInfo.remain수비 > -10 * 70) cityInfo.warn수비 = true;
if (cityInfo.remain성벽 > -10 * 70) cityInfo.warn성벽 = true;
if (cityInfo.warn농업) cityInfo.$농업.append('<span class="remain" style="color:yellow;">[' + cityInfo.remain농업 + ']</span>');
if (cityInfo.warn상업) cityInfo.$상업.append('<span class="remain" style="color:yellow;">[' + cityInfo.remain상업 + ']</span>');
if (cityInfo.warn치안) cityInfo.$치안.append('<span class="remain" style="color:yellow;">[' + cityInfo.remain치안 + ']</span>');
if (cityInfo.warn수비) cityInfo.$수비.append('<span class="remain" style="color:yellow;">[' + cityInfo.remain수비 + ']</span>');
if (cityInfo.warn성벽) cityInfo.$성벽.append('<span class="remain" style="color:yellow;">[' + cityInfo.remain성벽 + ']</span>');
}
//태수,군사,종사
{
cityInfo..$obj = $this.find('.officer-4-value');
cityInfo..$obj = $this.find('.officer-3-value');
cityInfo..$obj = $this.find('.officer-2-valuet');
}
//기타
{
cityInfo.userCnt = $this.find('.city-generals').text().split(',').length - 1;
}
cityInfo.obj = $this;
cityList[cityInfo.] = cityInfo as CityItem;
});
const $onGenList = $('<button type="button">암행부 연동</button>');
$onGenList.click(function () {
loadUser();
return false;
});
$('form').append($onGenList);
$('table:eq(0) tr:last').after('<tr><td id="sort_more"></td></tr>');
const $sort_more = $('#sort_more');
$sort_more.html('재 정렬 순서 :');
const sortIt = function (callback: (lhs: CityItem, rhs: CityItem) => number) {
let arCity: CityItem[] = [];
$('.cityInfo').each(function () {
const $this = $(this);
const cityName = $this.data('cityname');
const cityInfo = cityList[cityName];
arCity.push(cityInfo);
});
arCity = mergeSort(arCity, callback);
//console.log(arCity);
const $anchor = $('.anchor');
//console.log($anchor);
$('body > br').remove();
$('.cityInfo').detach();
$.each(arCity, function (idx, val) {
$anchor.before('<br>');
$anchor.before(val.obj);
});
$anchor.before('<br>');
};
let $btn: JQuery<HTMLElement>;
$btn = $('<button type="button">도시명</button>').click(function () {
sortIt(function (a, b) {
return a..localeCompare(b.);
});
});
$sort_more.append($btn);
$btn = $('<button type="button">인구율</button>').click(function () {
sortIt(function (a, b) {
return 1.0 * a. / a.max주민 - 1.0 * b. / b.max주민;
});
});
$sort_more.append($btn);
$btn = $('<button type="button">남은 주민</button>').click(function () {
sortIt(function (a, b) {
return a.remain주민 - b.remain주민;
});
});
$sort_more.append($btn);
$btn = $('<button type="button">남은 농업</button>').click(function () {
sortIt(function (a, b) {
return a.remain농업 - b.remain농업;
});
});
$sort_more.append($btn);
$btn = $('<button type="button">남은 상업</button>').click(function () {
sortIt(function (a, b) {
return a.remain상업 - b.remain상업;
});
});
$sort_more.append($btn);
$btn = $('<button type="button">남은 치안</button>').click(function () {
sortIt(function (a, b) {
return a.remain치안 - b.remain치안;
});
});
$sort_more.append($btn);
$btn = $('<button type="button">남은 수비</button>').click(function () {
sortIt(function (a, b) {
return a.remain수비 - b.remain수비;
});
});
$sort_more.append($btn);
$btn = $('<button type="button">남은 성벽</button>').click(function () {
sortIt(function (a, b) {
return a.remain성벽 - b.remain성벽;
});
});
$sort_more.append($btn);
$btn = $('<button type="button">배치 장수 수</button>').click(function () {
sortIt(function (a, b) {
return b.userCnt - a.userCnt;
});
});
$sort_more.append($btn);
};
mainFunc();
});
+294
View File
@@ -0,0 +1,294 @@
import $ from 'jquery';
import { unwrap } from '@util/unwrap';
import axios from 'axios';
import { setAxiosXMLHttpRequest } from '@util/setAxiosXMLHttpRequest';
import { getNpcColor } from './common_legacy';
declare const killturn: number;
declare const autorun_user: undefined|null|{
limit_minutes: number;
options: Record<string, number>;
};
declare const turnterm: number;
type KingdomGeneral = {
html: JQuery<HTMLElement>,
장수명: string
국가: string
벌점: number,
: number,
: number,
: number,
삭턴: number,
종류: UserType,
NPC: number,
}
type UserType = "통" | "무" | "지" | "만능" | "평범" | "무능" | "무지";
type NationInfo = {
[v in UserType]: KingdomGeneral[]
}
$(function () {
setAxiosXMLHttpRequest();
const $userFrame: JQuery<HTMLElement> = $('<div id="on_mover" style="position:absolute;">' +
'<table class="tb_layout bg0" style="width:100%;"><thead><tr>' +
'<td width="64" align="center" class="bg1">얼 굴</td>' +
'<td width="100" align="center" class="bg1">이 름</td>' +
'<td width="50" align="center" class="bg1">연령</td>' +
'<td width="50" align="center" class="bg1">성격</td>' +
'<td width="90" align="center" class="bg1">특기</td>' +
'<td width="50" align="center" class="bg1">레 벨</td>' +
'<td width="100" align="center" class="bg1">국 가</td>' +
'<td width="60" align="center" class="bg1">명 성</td>' +
'<td width="60" align="center" class="bg1">계 급</td>' +
'<td width="80" align="center" class="bg1">관 직</td>' +
'<td width="45" align="center" class="bg1">통솔</td>' +
'<td width="45" align="center" class="bg1">무력</td>' +
'<td width="45" align="center" class="bg1">지력</td>' +
'<td width="45" align="center" class="bg1">삭턴</td>' +
'<td width="84" align="center" class="bg1">벌점</td>' +
'</tr></thead><tbody class="content"></tbody></table></div>');
$userFrame.find('thead td');
$userFrame.css('width', '1000px').css('margin', '0').css('padding', '0').css('left', '50%').css('margin-left', '-500px');
$userFrame.css('box-shadow', '0px 0px 7px 3px rgba(255,255,255,50)');
$userFrame.hide();
const = $('table:gt(0):lt(-2)');
const getUserType = function (: number, : number, : number): UserType {
//const 총 = 통 + 무 + 지;
if ( < 40) {
if ( + < 40) {
return "무능";
}
return "무지";
}
const = Math.max(, , );
const 2 = Math.min( + , + , + );
if ( >= 70 && 2 >= * 1.7) {
return "만능";
}
if ( >= 60 && < * 0.8) {
return "무";
}
if ( >= 60 && < * 0.8) {
return "지";
}
if ( >= 60 && + < ) {
return "통";
}
return "평범";
};
function formatScore(x: number) {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
const runAnalysis = async function () {
let realKillturn = killturn;
if(autorun_user && autorun_user.limit_minutes){
realKillturn -= autorun_user.limit_minutes / turnterm;
}
const $content = $('#on_mover .content');
try {
const response = await axios({ url: 'a_genList.php', method: 'get', responseType: 'text' });
const rawData = response.data; const $html = $(rawData);
let $장수일람: JQuery<HTMLElement> | undefined;
const 국가별: Record<string, NationInfo> = {};
let cnt = 0;
$html.each(function () {
if (this.tagName == "TABLE") {
cnt += 1;
if (cnt == 2) {
$장수일람 = $(this);
return false;
}
}
});
if ($장수일람 !== undefined) {
$장수일람.find('tr:gt(0)').each(function () {
const = {} as KingdomGeneral;
const $this = $(this);
const $tds = $this.find('td');
const = $.trim($tds.eq(1).text());
const = $.trim($tds.eq(6).text());
//const 부상 = $this.data('general-wounded');
.html = $this.clone();
. = ;
. = ;
. = parseInt($tds.eq(-1).text());
. = parseInt($this.data('general-leadership'));
. = parseInt($this.data('general-strength'));
. = parseInt($this.data('general-intel'));
. = parseInt($tds.eq(-2).text());
. = getUserType(., ., .);
.NPC = $this.data('npc-type');
if (!( in )) {
[] = {
: [],
: [],
: [],
: [],
: [],
: [],
: [],
//NPC:[],
};
}
//if(장수.NPC) 국가별[국가].NPC.push(장수);
[][.].push();
.html.hide();
$content.append(.html);
});
}
.each(function () {
const $this = $(this);
const $tbl = $this;
const $td = $this.find('td:last');
let name = $.trim($this.find('td:first').text());
name = name.substr(2, name.length - 4);
const = [name];
let total = 0;
let = 0;
let = 0;
let = 0;
let N장수 = 0;
let N장통솔합 = 0;
$td.html('<p class="sum" style="margin:0;font-weight:bold;color:yellow;text-align:center"></p>');
$td.css('text-indent', '-5.8em').css('padding-left', '5.8em');
for (const [, ] of Object.entries()) {
const $p = $("<p></p>").css('margin', '0');
if (.length == 0) continue;
.sort(function (, ) {
if (. == .) {
return . > . ? 1 : 0;
}
return . - .
});
let text = "  " + ;
text = text.substr(text.length - 2);
$p.append(text + '장(');
text = "" + .length;
$p.append(text + ')');
if (text.length < 3) {
$p.append("<span style='display:inline-block;width:" + (3 - text.length) / 2 + "em;'>&nbsp;</span>");
}
$p.append(': ');
total += .length;
$.each(, function (idx, val) {
//const 종능 = val.통 + val.무 + val.지;
console.log(val);
if ( != '무능' && != '무지') {
if (val. >= realKillturn && val.NPC < 2) {
+= 1;
+= val.;
}
if (val. > 5 && val.NPC >= 2) {
N장수 += 1;
N장통솔합 += val.;
}
}
const $obj = $('<span></span>');
const $obj2 = $('<span></span>');
$obj.html(val.);
if (val.NPC < 2 && val. < realKillturn) {
$obj.css('text-decoration', 'line-through');
+= 1;
}
const colorNPC = getNpcColor(val.NPC);
if (colorNPC !== undefined) {
$obj.css('color', colorNPC);
}
if (val. >= 1500) $obj.css('color', 'yellow');
else if (val. >= 200) $obj.css('color', 'lightgreen');
$obj2.append($obj);
if (idx < .length - 1) {
$obj2.append(', ');
}
$p.append($obj2);
$obj2.hover(function () {
const top = unwrap($tbl.offset()).top + unwrap($tbl.outerHeight()) + 3;
$userFrame.css('top', top);
val.html.show();
$userFrame.show();
console.log('올림!' + val.);
}, function () {
$userFrame.hide();
val.html.hide();
console.log('내림!' + val.);
});
});
$td.append($p);
}
const result = "* 총(" + total + "), 전투장(" + + ", 약 " + formatScore( * 100) + "명), 전투N장(" + N장수 + ", 약 " + formatScore(N장통솔합 * 100) + "명), 삭턴장(" + + ") *";
$tbl.find('.sum').html(result);
});
}
catch (err) {
console.error(err);
}
}
$('body').append($userFrame);
const $frame = $('table:eq(0) td:eq(0)');
$frame.find('br:last').remove();
const $btn = $('<input type="button" value="장수 일람 연동">');
$btn.on('click', function () {
void runAnalysis();
$btn.prop("disabled", true);
const $tr0 = $('table:eq(0) tr:eq(0)');
$tr0.append('<td><strong>*벌점 순 정렬*</strong><br><span style="color:yellow">벌점 1500점 이상</span>, <span style="color:lightgreen">벌점 200점 이상</span>, ' +
'<span style="text-decoration:line-through">삭턴장</span>, <span style="color:cyan">ⓝ장</span>' + '<br><strong>전투장 :</strong> 만능장 + 무장 + 지장 + 평범장 - 삭턴자(만능, 무, 지, 평범) </td>');
});
$frame.append($btn);
});
+124
View File
@@ -0,0 +1,124 @@
import axios from "axios";
import { unwrap } from "@util/unwrap";
import { RuntimeError } from "@util/RuntimeError";
declare global {
interface Window {
userList: Record<number, JQuery<HTMLElement>>;
}
}
export function launchTroopPlugin($: JQueryStatic): void {
let userList: Record<number, JQuery<HTMLElement>> = {};
const basicPath = (() => {
const path = document.location.pathname;
return path.substring(0, path.lastIndexOf('/'));
})();
const $userFrame: JQuery<HTMLElement> = $("<div id='on_mover' style='position:absolute;'>" +
"<table class='tb_layout bg0' style='width:100%;'><thead>" +
"<tr>" +
"<td width=98 class='bg1 center'>이 름</td>" +
"<td width=98 class='bg1 center'>통무지</td>" +
"<td width=53 class='bg1 center'>자 금</td>" +
"<td width=53 class='bg1 center'>군 량</td>" +
"<td width=48 class='bg1 center'>도시</td>" +
"<td width=28 class='bg1 center'>守</td>" +
"<td width=58 class='bg1 center'>병 종</td>" +
"<td width=63 class='bg1 center'>병 사</td>" +
"<td width=38 class='bg1 center'>훈련</td>" +
"<td width=38 class='bg1 center'>사기</td>" +
"<td width=213 class='bg1 center'>명 령</td>" +
"<td width=38 class='bg1 center'>삭턴</td>" +
"<td width=48 class='bg1 center'>턴</td>" +
"</tr>" +
"</thead></thead><tbody class='content'></tbody></table></div>");
$userFrame.find('thead td');
$userFrame.css('width', '960px').css('margin', '0').css('padding', '0').css('left', '50%').css('margin-left', '-480px');
$userFrame.hide();
const runAnalysis = async function () {
userList = {};
const $content = $('#on_mover .content');
$content.html('');
const response = await axios.get(`${basicPath}/b_genList.php`, {responseType: 'text'});
const rawData = response.data;
try {
const $html = $(rawData);
const tmpUsers: JQuery<HTMLElement> = (() => {
let tmpUsers = undefined;
$html.each(function () {
const $this = $(this);
if ($this.attr('id') == "general_list") {
tmpUsers = $(this);
return false;
}
});
if (tmpUsers === undefined) {
throw new RuntimeError();
}
return tmpUsers;
})()
tmpUsers.find("tbody > tr").each(function () {
const $this = $(this);
const $부대 = $this.children('.i_troop');
const = $.trim($부대.text());
if ( == '-') {
//부대 안탔음!
return;
}
$부대.remove();
const generalID = parseInt($this.data('general-id'));
userList[generalID] = $this;
$this.hide();
$content.append($this);
});
$('.troopUser').hover(function () {
const $this = $(this);
const parent = $this.closest('tr');
const generalID = parseInt($this.data('general-id'));
console.log(generalID);
const top = unwrap(parent.offset()).top + unwrap(parent.outerHeight());
$userFrame.css('top', top);
userList[generalID].show();
$userFrame.show();
}, function () {
const $this = $(this);
const generalID = parseInt($this.data('general-id'));
userList[generalID].hide();
$userFrame.hide();
});
}
catch (err) {
console.log(err);
}
};
const $frame = $('table:eq(0) td:eq(0)');
$frame.find('br:last').remove();
const $btn = $('<input type="button" value="암행부 연동">');
$btn.click(async function () {
await runAnalysis();
});
$frame.append($btn);
window.userList = userList;
$('body').append($userFrame);
}
+284
View File
@@ -0,0 +1,284 @@
import axios from 'axios';
import $ from 'jquery';
import { isNumber } from 'lodash';
import { TemplateEngine } from '@util/TemplateEngine';
import type { InvalidResponse } from '@/defs';
import { setAxiosXMLHttpRequest } from '@util/setAxiosXMLHttpRequest';
import { unwrap_any } from '@util/unwrap_any';
import { convertFormData } from '@util/convertFormData';
import { exportWindow } from '@util/exportWindow';
import '@/gateway/common';
type UserEntry = {
userID: string,
userName: string,
email: string,
authType: string | null,
userGrade: number,
blockUntil: string | null,
nickname: string,
icon: string,
joinDate: string,
loginDate: string,
deleteAfter: string | null,
}
type UserListResponse = {
result: true,
users: UserEntry[],
servers: string[],
allowJoin: boolean,
allowLogin: boolean,
}
const userFrame = '\
<tr id="userinfo_<%userID%>" data-username="<%userName%>" data-id="<%userID%>">\
<th scope="row"><%userID%></th>\
<td><%userName%></td>\
<td class="small"><%emailFunc(email)%><br>(<%authType%>)</td>\
<td><%userGradeText%><p class="small hide_text user_grade_<%userGrade%>" style="margin:0;"><%shortDate(blockUntil)%></p></td>\
<td><%nickname%></td>\
<td><img class="generalIcon" src="<%icon%>" width="64" height="64"></td>\
<td class="small"><%slotGeneralList%></td>\
<td class="small"><%shortDate(joinDate)%></td>\
<td class="small"><%shortDate(loginDate)%></td>\
<td class="small"><%shortDate(deleteAfter)%></td>\
<td>\
<div class="btn-group" role="group">\
<button type="button" onclick="changeUserStatus(\'delete\', this);" class="btn btn-danger btn-sm">강제<br>탈퇴</button>\
<button type="button" onclick="changeUserStatus(\'reset_pw\', this);" class="btn btn-info btn-sm">암호<br>변경</button>\
<button type="button" onclick="changeUserStatus(\'block\', this);" class="btn btn-warning btn-sm">유저<br>차단</button>\
<button type="button" onclick="changeUserStatus(\'unblock\', this);" class="btn btn-secondary btn-sm">차단<br>해제</button>\
<button type="button" onclick="changeUserStatus(\'set_userlevel\', this);" class="btn btn-primary btn-sm">별도<br>권한</button>\
</div>\
</td>\
</tr>';
function convUserGrade(grade: number): string {
const userGradeMap = {
0: '차단',
1: '일반',
4: '특별',
5: '부운영자',
6: '운영자'
};
if (grade in userGradeMap) {
return userGradeMap[grade as (keyof typeof userGradeMap)];
}
return grade.toString();
}
function fillAllowJoinLogin(result: UserListResponse) {
if (result.allowJoin) {
$('#allow_join_y').trigger('click');
}
else {
$('#allow_join_n').trigger('click');
}
if (result.allowLogin) {
$('#allow_login_y').trigger('click');
}
else {
$('#allow_login_n').trigger('click');
}
}
function fillUserList(result: UserListResponse) {
const $user_list = $('#user_list');
$user_list.empty();
const slotGeneralList = $.map(result.servers, function (value) {
return `<span class="server_generalName_${value}"></span>`;
}).join('<br>');
const emailFunc = function (text: string) {
return String(text).replace('@', '@<br>');
}
const brFunc = function (text: string) {
return String(text).split(' ').join('<br>');
};
const shortDateFunc = function (date: string | null) {
if (!date) {
return '-';
}
return brFunc(date.substr(2));
}
$.each(result.users, function (idx, user) {
const templateItem = {
...user,
email: user.email ?? '-',
br: brFunc,
shortDate: shortDateFunc,
emailFunc: emailFunc,
slotGeneralList: slotGeneralList,
userGradeText: convUserGrade(user.userGrade),
}
$user_list.append(
TemplateEngine(userFrame, templateItem)
)
});
//TODO: slotGeneralList에 값을 채워야함. ajax로 받아올 필요 있음
}
async function changeSystem(action: string, param?: string): Promise<void> {
const text = `${action}${param ? (', ' + param) : ''}을 진행합니다.`;
if (!confirm(text)) {
return;
}
let result: InvalidResponse | {
result: true,
affected?: number,
};
try {
const response = await axios({
url: 'j_set_userlist.php',
method: 'post',
responseType: 'json',
data: convertFormData({
'action': action,
'param': param ?? null
})
})
result = response.data;
}
catch (e) {
console.error(e);
alert(`실패했습니다: ${e}`);
return;
}
if (!result.result) {
alert(result.reason);
return;
}
if (result.affected) {
alert(`${result.affected}건이 처리되었습니다.`);
await refreshAll();
}
else {
alert('완료되었습니다.');
}
}
async function changeUserStatus(action: string, userID: Element | number, param?: number): Promise<void> {
if (userID instanceof Element) {
userID = parseInt($(userID).parents('tr').data('id'));
}
if (!isNumber(userID)) {
alert('userID가 올바르게 지정되지 않았습니다!');
return;
}
if (action == 'set_userlevel') {
if (!isNumber(param)) {
param = parseInt(prompt('원하는 등급을 입력해주세요.(1:일반, 4:특별, 5:부운영자, 6:운영자)', '1') ?? '0');
}
if (param < 1 || param > 6) {
alert('올바르지 않습니다.');
return;
}
}
if (action == 'block') {
if (!isNumber(param)) {
param = parseInt(prompt('블록 기간을 입력해주세요. <= 0은 반영구(50년)입니다.', '7') ?? '7');
}
}
const userName = unwrap_any<string>($('#userinfo_' + userID).data('username'));
const text = `${userName}에 대해서 ${action}${param ? (', ' + param) : ''}을 진행합니다.`;
if (!confirm(text)) {
return;
}
let result: InvalidResponse | {
result: true,
detail?: string,
};
try {
const response = await axios({
url: 'j_set_userlist.php',
method: 'post',
responseType: 'json',
data: convertFormData({
'action': action,
'user_id': userID,
'param': param ?? null
})
})
result = response.data;
}
catch (e) {
console.error(e);
alert(`실패했습니다: ${e}`);
return;
}
if (!result.result) {
alert(result.reason);
return;
}
if (result.detail) {
alert(`완료되었습니다: ${result.detail}`);
}
else {
alert('완료되었습니다.');
}
await refreshAll();
}
async function refreshAll() {
let result: InvalidResponse | UserListResponse;
try {
const response = await axios({
url: 'j_get_userlist.php',
method: 'post',
responseType: 'json',
});
result = response.data;
}
catch (e) {
console.error(e);
alert(`실패했습니다: ${e}`);
return;
}
if (!result.result) {
alert(result.reason);
return;
}
fillAllowJoinLogin(result);
fillUserList(result);
}
$(async function () {
setAxiosXMLHttpRequest();
await refreshAll();
$('input[name=allow_join], input[name=allow_login]').on('change', async function () {
const $this = $(this);
await changeSystem(unwrap_any<string>($this.attr('name')), unwrap_any<string>($this.val()));
})
});
exportWindow(changeSystem, 'changeSystem');
exportWindow(changeUserStatus, 'changeUserStatus');
+320
View File
@@ -0,0 +1,320 @@
import { exportWindow } from '@util/exportWindow';
import axios from 'axios';
import { setAxiosXMLHttpRequest } from '@util/setAxiosXMLHttpRequest';
import type { InvalidResponse } from '@/defs';
import { convertFormData } from '@util/convertFormData';
import { TemplateEngine } from "@util/TemplateEngine";
import { unwrap_any } from '@util/unwrap_any';
import { unwrap } from '@util/unwrap';
import '@/gateway/common';
type ServerUpdateResponse = {
result: true,
version: string,
server: string,
imgResult: false,
}
type ServerRootUpdateResponse = {
result: true,
version: string,
server: string,
imgResult: true,
imgDetail: string,
}
type ServerStateItem = {
name: string,
korName: string,
color: string,
isRoot: boolean,
lastGitPath: string,
valid: boolean,
run: boolean,
installed: boolean,
version: string,
reason: string,
status?: string,
}
type ServerStateResponse = {
acl: Record<string, string[]>,
server: ServerStateItem[],
grade: number,
}
type ServerChangeResponse = {
result: true,
installURL?: string,
}
const serverAdminTemplate = '\
<tr class="bg0 server_admin_<%name%>" data-server_name="<%name%>" data-is_root="<%isRoot%>" data-git-path="<%lastGitPath%>">\
<th style="color:<%color%>;"><%korName%>(<%name%>)</th>\
<td><%status%></td>\
<td><%version%></td>\
<td><button type="button" class="serv_act_close with_skin valid_if_set with_border obj_fill" onclick="modifyServerStatus(this, \'close\');">폐쇄</button></td>\
<td><button type="button" class="serv_act_open with_skin valid_if_set with_border obj_fill" onclick="modifyServerStatus(this, \'open\');">오픈</button></td>\
<td><a class="just_link" href="../<%name%>/install.php"><button type="button" class="serv_act_reset with_skin valid_if_set with_border obj_fill">리셋</button></a></td>\
<td><a class="just_link" href="../<%name%>/install_db.php"><button type="button" class="serv_act_hard_reset with_skin valid_if_installed only_admin with_border obj_fill">하드리셋</button></a></td>\
<td><button type="button" class="serv_act_119 with_skin valid_if_set with_border obj_fill" onclick="Entrance_AdminOpen119(this);">서버119</button></td>\
<td><button type="button" class="serv_act_update with_skin with_border obj_fill" onclick="serverUpdate(this);">업데이트</button></td>\
</tr>\
';//TODO: npm install 관련 기능 추가, js/css output 경로 변경
declare global {
interface Window {
adminGrade: number;
aclList: Record<string, string[]>;
}
}
async function serverUpdate(caller: HTMLElement) {
const $caller = $(caller);
const $tr = $caller.parents('tr');
const server = unwrap_any<string>($tr.data('server_name'));
let isRoot: string | boolean = unwrap_any<string>($tr.data('is_root'));
let target = $tr.data('gitPath');
if (typeof isRoot !== 'boolean') {
isRoot = (isRoot != 'false');
}
let allowFullUpdate = (server in window.aclList && window.aclList[server].indexOf('fullUpdate') >= 0);
allowFullUpdate = allowFullUpdate || window.adminGrade > 5;
let allowUpdate = (server in window.aclList && window.aclList[server].indexOf('update') >= 0);
allowUpdate = allowUpdate || window.adminGrade >= 5;
allowUpdate = allowUpdate || allowFullUpdate;
if (!allowUpdate) {
alert('권한이 없습니다!');
return;
}
if (allowFullUpdate) {
target = prompt('가져올 git tree-ish 명을 입력해주세요.', target)
if (!target) {
return;
}
}
else if (isRoot) {
if (!confirm('서버 라이브러리, 루트 서버에 대해 git pull을 실행합니다.')) {
return;
}
}
else if (!confirm('다음 git tree-ish 주소로 업데이트를 시도합니다 : ' + target)) {
return;
}
let result: InvalidResponse | ServerUpdateResponse | ServerRootUpdateResponse;
try {
const response = await axios({
url: '../j_updateServer.php',
responseType: 'json',
method: 'post',
data: convertFormData({
server: server,
target: target
})
});
result = response.data;
} catch (e) {
console.error(e);
alert(`에러: ${e}`);
location.reload();
return;
}
if (!result.result) {
alert(`실패했습니다: ${result.reason}`);
return;
}
let aux = '';
if (result.imgResult) {
aux = ` (이미지 서버 갱신:${result.imgResult}, ${result.imgDetail})`;
}
alert(`${result.server} 서버가 ${result.version} 버전으로 업데이트 되었습니다.${aux}`);
}
function drawServerAdminList(serverList: ServerStateResponse) {
const $table = $('#server_admin_list');
const $showErrorLog = $('#showErrorLog');
if (serverList.grade >= 5) {
$showErrorLog.show();
}
$.each(serverList.server, function (idx, server) {
console.log(server);
let status: string;
if (!server.valid) {
status = `에러, ${server.reason}`;
}
else if (!server.run) {
status = '폐쇄됨';
}
else {
status = '운영 중';
}
server.status = status;
const $tr = $(TemplateEngine(serverAdminTemplate, server));
$table.append($tr);
if (serverList.grade < 4) {
$tr.find('button').prop('disabled', true);
}
if (!server.valid) {
$tr.find('.valid_if_set').prop('disabled', true);
}
if (!server.installed) {
$tr.find('.valid_if_installed').prop('disabled', true);
}
const aclByServer = serverList.acl[server.name];
$.each(aclByServer, function (idx, action) {
console.log(action);
if (action == 'update' || action == 'fullUpdate') {
if (!server.installed) {
return true;
}
$tr.find('.serv_act_update').prop('disabled', false);
$showErrorLog.show();
}
else if (action == 'openClose') {
if (!server.valid) {
return true;
}
$tr.find('.serv_act_open, .serv_act_close').prop('disabled', false);
}
else if (action == 'reset') {
if (!server.installed) {
return true;
}
$tr.find('.serv_act_reset, .serv_act_close').prop('disabled', false);
}
});
});
window.adminGrade = serverList.grade;
window.aclList = serverList.acl;
if (serverList.grade <= 5) {
$table.find('.only_admin').prop('disabled', true);
}
}
export async function loadPlugin(): Promise<void> {
setAxiosXMLHttpRequest();
Entrance_AdminInit();
const response = await axios({
url: 'j_server_get_admin_status.php',
method: 'post',
responseType: 'json'
});
drawServerAdminList(response.data);
}
function Entrance_AdminInit() {
console.log('adminInit');
$("#Entrance_000202").on('click', Entrance_Member);
$("#notice_change_btn").on('click', Entrance_AdminChangeNotice);
}
function Entrance_Member(e: JQuery.Event) {
e.preventDefault();
$("#Entrance_00").hide();
$("#EntranceMember_00").show();
}
async function Entrance_AdminChangeNotice(e: JQuery.Event) {
e.preventDefault();
const notice = unwrap_any<string>($("#notice_edit").val());
if (!confirm('정말 실행하시겠습니까?')) {
return;
}
let result: InvalidResponse | ServerChangeResponse;
try {
const response = await axios({
url: 'j_server_change_status.php',
method: 'post',
responseType: 'json',
data: convertFormData({
action: 'notice',
notice: notice
})
});
result = response.data;
}
catch (e) {
console.error(e);
alert(`실패했습니다: ${e}`);
return;
}
if (!result.result) {
alert(`실패했습니다: ${result.reason}`);
return;
}
location.reload();
}
async function modifyServerStatus(caller: HTMLElement, action: string) {
const $caller = $(caller);
const server = unwrap_any<string>($caller.parents('tr').data('server_name'));
if (!confirm('정말 실행하시겠습니까?')) {
return;
}
let result: InvalidResponse | ServerChangeResponse;
try {
const response = await axios({
url: 'j_server_change_status.php',
method: 'post',
responseType: 'json',
data: convertFormData({
server: server,
action: action
})
});
result = response.data;
}
catch (e) {
console.error(e);
alert(`실패했습니다: ${e}`);
return;
}
if (!result.result) {
alert(`실패했습니다: ${result.reason}`);
return;
}
if (action == 'reset') {
location.href = unwrap(result.installURL);
} else {
location.reload();
}
}
function Entrance_AdminOpen119(caller: HTMLElement) {
const $caller = $(caller);
const serverDir = $caller.parents('tr').data('server_name');
location.href = `../${serverDir}/_119.php`;
}
exportWindow(modifyServerStatus, 'modifyServerStatus');
exportWindow(Entrance_AdminOpen119, 'Entrance_AdminOpen119');
exportWindow(serverUpdate, 'serverUpdate');
+3
View File
@@ -0,0 +1,3 @@
import 'bootstrap';
import "@scss/common/bootstrap5.scss";
+297
View File
@@ -0,0 +1,297 @@
import { exportWindow } from '@util/exportWindow';
import $ from 'jquery';
exportWindow($, '$');
import axios from 'axios';
import { initTooltip } from "@/legacy/initTooltip";
import { TemplateEngine } from '@util/TemplateEngine';
import type { InvalidResponse } from '@/defs';
import { getDateTimeNow } from '@util/getDateTimeNow';
import { setAxiosXMLHttpRequest } from '@util/setAxiosXMLHttpRequest';
import { loadPlugin as loadAdminPlugin } from '@/gateway/admin_server';
import '@/gateway/common';
declare const isAdmin: boolean;
const serverListTemplate = "\
<tr class='server_item bg0 server_name_<%name%>' data-server='<%name%>'>\
<td class='server_name obj_tooltip' data-bs-toggle='tooltip' data-bs-placement='bottom'>\
<span style='font-weight:bold;font-size:1.4em;color:<%color%>'><%korName%>섭</span><br>\
<span class='n_country'></span>\
<span class='tooltiptext server_date'></span>\
</td>\
<td colspan='4' class='server_down'>- 폐 쇄 중 -</td>\
</tr>\
";
const serverTextInfo = "\
<td>\
서기 <%year%>년 <%month%>월 (<span style='color:orange;'><%scenario%></span>)<br>\
유저 : <%userCnt%> / <%maxUserCnt%>명 <span style='color:cyan;'>NPC : <%npcCnt%>명</span> (<span style='color:limegreen;'><%turnTerm%>분 턴 서버</span>)<br>\
(상성 설정:<%fictionMode%>), (기타 설정:<%otherTextInfo%>)\
</td>\
";
const serverProvisionalInfo = "\
<td>\
- 오픈 일시 : <%opentime%> -<br>\
서기 <%year%>년 <%month%>월 (<span style='color:orange;'><%scenario%></span>)<br>\
유저 : <%userCnt%> / <%maxUserCnt%>명 <span style='color:cyan;'>NPC : <%npcCnt%>명</span> (<span style='color:limegreen;'><%turnTerm%>분 턴 서버</span>)<br>\
(상성 설정:<%fictionMode%>), (기타 설정:<%otherTextInfo%>)\
</td>\
";
const serverFullTemplate = "\
<td colspan='4' class='server_full'>- 장수 등록 마감 -</td>\
";
const serverCreateTemplate = "\
<td colspan='2' class='not_registered'>- 미 등 록 -\
<td class='ignore_border'>\
<div class='d-grid'>\
<%if(canCreate) {%>\
<a class='btn btn-secondary server-action' href='<%serverPath%>/v_join.php'>장수생성</a>\
<%}%>\
<%if(canSelectNPC) {%>\
<a class='btn btn-secondary server-action' href='<%serverPath%>/select_npc.php'>장수빙의</a>\
<%}%>\
<%if(canSelectPool) {%>\
<a class='btn btn-secondary server-action' href='<%serverPath%>/select_general_from_pool.php'>장수선택</a>\
<%}%>\
</div></td>";
const serverLoginTemplate = "\
<td style='background:url(\"<%picture%>\");background-size: 64px 64px;background-repeat: no-repeat;background-position: center center;'></td>\
<td><%name%></td>\
<td class='ignore_border'>\
<div class='d-grid'>\
<a href='<%serverPath%>/' class='btn btn-secondary server-action'>입장</a>\
</div></td>\
";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const serverReservedTemplate = "\
<td colspan='4' class='server_reserved'>\
<%openDatetime==starttime?'':'- 가오픈 일시 : '+openDatetime+ '- <br>'%>\
- 오픈 일시 : <%starttime%> - <br>\
<span style='color:orange;'><%scenarioName%></span> <span style='color:limegreen;'><%turnterm%>분 턴 서버</span><br>\
(상성 설정:<%fictionMode%>), (빙의 여부:<%npcMode%>), (최대 스탯:<%gameConf.defaultStatTotal%>), (기타 설정:<%otherTextInfo%>)\
</td>\
";
type ServerResponseItem = {
color: string,
korName: string,
name: string,
exists: boolean,
enable: boolean,
};
type ServerResponse = {
result: true;
reason: 'success';
server: ServerResponseItem[];
}
type RawServerResponse = InvalidResponse | ServerResponse;
type ReservedGameInfo = {
scenarioName: string,
turnterm: number,
fictionMode: '가상' | '사실',
block_general_create: boolean,
npcMode: '불가' | '가능' | '선택 생성',
openDatetime: string,
starttime: string,
gameConf: Record<string, string | number>,
otherTextInfo: string,
}
type GameInfo = {
isUnited: number,
npcMode: '불가' | '가능' | '선택 생성',
year: number,
month: number,
scenario: string,
maxUserCnt: number,
turnTerm: number,
opentime: string,
starttime: string,
turntime: string,
join_mode: string,
fictionMode: '가상' | '사실',
block_general_create: boolean,
autorun_user: string,
userCnt: number,
npcCnt: number,
nationCnt: number,
otherTextInfo: string,
defaultStatTotal: number,
}
type MyInfo = {
name: string,
picture: string,
serverPath?: string,
}
type ServerDetailResponse = {
reserved?: ReservedGameInfo;
game: GameInfo;
me: MyInfo | null | undefined;
}
$(function ($) {
setAxiosXMLHttpRequest();
$("#btn_logout").on('click', Entrance_Logout);
void Entrance_UpdateServer();
if(isAdmin){
void loadAdminPlugin();
}
});
async function Entrance_UpdateServer() {
let data: RawServerResponse;
try {
const response = await axios({
url: 'j_server_get_status.php',
responseType: 'json',
method: 'post'
});
data = response.data;
}
catch (e) {
alert(e);
return;
}
if (!data.result) {
alert(data.reason);
return;
}
await Entrance_drawServerList(data.server);
}
async function Entrance_drawServerList(serverInfos: ServerResponseItem[]) {
const $serverList = $('#server_list');
const now = getDateTimeNow();
const serverDetailInfoP: Record<string, Promise<ServerDetailResponse>> = {};
for (const serverInfo of serverInfos) {
if(!serverInfo.exists){
continue;
}
const responseP = axios({
url: `../${serverInfo.name}/j_server_basic_info.php`,
method: 'get',
responseType: 'json'
}).then(v=>{
return v.data as ServerDetailResponse;
});
serverDetailInfoP[serverInfo.name] = responseP;
}
for (const serverInfo of serverInfos) {
const $serverHtml = $(TemplateEngine(serverListTemplate, serverInfo));
$serverList.append($serverHtml);
if (!serverInfo.exists) {
continue;
}
const serverPath = `../${serverInfo.name}`;
let response: ServerDetailResponse;
try{
if(!(serverInfo.name in serverDetailInfoP)){
continue;
}
response = await serverDetailInfoP[serverInfo.name];
}
catch(e){
console.error(e);
continue;
}
if(response.reserved){
$serverHtml.find('.server_down').detach();
$serverHtml.append(
TemplateEngine(serverReservedTemplate, response.reserved)
);
initTooltip($serverHtml);
}
if(!response.game){
continue;
}
const game = response.game;
//TODO: 서버 폐쇄 방식을 새롭게 변경
$serverHtml.find('.server_down').detach();
if (game.isUnited == 3) {
$serverHtml.find('.n_country').html('§이벤트 종료§');
$serverHtml.find('.server_date').html(`${game.starttime} <br>~ ${game.turntime}`);
} else if (game.isUnited == 1) {
$serverHtml.find('.n_country').html('§이벤트 진행중§');
$serverHtml.find('.server_date').html(`${game.starttime} ~`);
} else if (game.isUnited == 2) {
$serverHtml.find('.n_country').html('§천하통일§');
$serverHtml.find('.server_date').html(`${game.starttime} <br>~ ${game.turntime}`);
} else if (game.opentime <= now) {
$serverHtml.find('.n_country').html(`<${game.nationCnt}국 경쟁중>`);
$serverHtml.find('.server_date').html(`${game.starttime} ~`);
} else {
$serverHtml.find('.n_country').html('-가오픈 중-');
$serverHtml.find('.server_date').html(`${game.starttime} ~`);
}
if (game.opentime <= now) {
$serverHtml.append(
TemplateEngine(serverTextInfo, game)
);
} else {
$serverHtml.append(
TemplateEngine(serverProvisionalInfo, game)
);
}
if (response.me && response.me.name) {
const me = response.me;
me.serverPath = serverPath;
$serverHtml.append(
TemplateEngine(serverLoginTemplate, me)
);
} else if (game.userCnt >= game.maxUserCnt) {
$serverHtml.append(
TemplateEngine(serverFullTemplate, {})
);
} else {
$serverHtml.append(
TemplateEngine(serverCreateTemplate, {
serverPath: serverPath,
canCreate: !game.block_general_create,
canSelectNPC: game.npcMode == '가능',
canSelectPool: game.npcMode == '선택 생성'
})
)
}
initTooltip($serverHtml);
}
}
async function Entrance_Logout() {
const response = await axios({
url: 'j_logout.php',
method: 'post',
responseType: 'json'
});
const data: InvalidResponse = response.data;
if(!data.result){
alert(`로그아웃 실패: ${data.reason}`);
return;
}
location.href = "../";
}
+316
View File
@@ -0,0 +1,316 @@
import $ from 'jquery';
import axios from 'axios';
import { setAxiosXMLHttpRequest } from '@util/setAxiosXMLHttpRequest';
import type { InvalidResponse } from '@/defs';
import { JQValidateForm, type NamedRules } from '@util/jqValidateForm';
import { convertFormData } from '@util/convertFormData';
import { unwrap_any } from '@util/unwrap_any';
import { mb_strwidth } from '@util/mb_strwidth';
import { isString } from 'lodash';
import { sha512 } from 'js-sha512';
import '@/gateway/common';
async function changeInstallMode() {
let result: {
step: string,
globalSalt: string,
};
try {
const response = await axios({
url: 'j_install_status.php',
method: 'post',
responseType: 'json'
});
result = response.data;
} catch (e) {
console.error(e);
alert(`에러: ${e}`);
return;
}
if (result.step == 'config') {
$('#db_form_card').show();
$('#admin_form_card').hide();
return;
}
if (result.step == 'admin') {
$('#db_form_card').hide();
$('#admin_form_card').show();
$('#global_salt').val(result.globalSalt);
return;
}
if (result.step == 'done') {
alert('설치가 완료되었습니다.');
window.location.href = "..";
return;
}
if (result.step == 'conn_fail') {
$('#db_form_card').hide();
$('#admin_form_card').hide();
alert('설치 이후 DB 설정이 변경된 것 같습니다. RootDB.php 파일의 설정을 확인해주십시오.');
return;
}
if (result.step == 'sql_fail') {
$('#db_form_card').hide();
$('#admin_form_card').hide();
alert('DB가 제대로 설정되지 않았거나, 훼손된 것 같습니다. DB를 복구하거나 RootDB.php 파일을 삭제 후 재설치를 진행해 주십시오.');
return;
}
alert(`알 수 없는 오류: ${result}`);
}
function setupDBForm() {
const parentPathname = location.pathname.split('/').slice(0, -2).join('/');
$('#serv_host').val(
[location.protocol, '//', location.host, parentPathname].join('')
);
$('#btn_random_generate_key').on('click', function (e) {
e.preventDefault();
let token = '';
while (token.length < 24) {
token += (Math.random() + 1).toString(36).substring(7);
}
token = token.substr(0, 24);
$('#image_request_key').val(token);
});
type DBFormType = {
db_host: string,
db_port: string,
db_id: string,
db_pw: string,
db_name: string,
serv_host: string,
shared_icon_path: string,
game_image_path: string,
image_request_key: string,
kakao_rest_key: string,
kakao_admin_key: string,
}
const descriptor: NamedRules<DBFormType> = {
db_host: {
required: true,
type: 'string',
},
db_port: {
required: true,
type: 'number',
transform: parseInt,
},
db_id: {
required: true,
type: 'string',
},
db_pw: {
required: true,
type: 'string',
},
db_name: {
required: true,
type: 'string',
},
serv_host: {
required: true,
type: 'string',
},
shared_icon_path: {
required: true,
type: 'string',
},
game_image_path: {
required: true,
type: 'string',
},
image_request_key: {
required: false,
type: 'string',
min: 16,
},
kakao_rest_key: {
required: false,
type: 'string',
},
kakao_admin_key: {
required: false,
type: 'string',
}
}
const validator = new JQValidateForm($('#db_form'), descriptor);
validator.installChangeHandler();
$('#db_form').on('submit', async function (e) {
e.preventDefault();
const values = await validator.validate();
if (values === undefined) {
return;
}
let result: InvalidResponse;
try {
const response = await axios({
url: 'j_setup_db.php',
method: 'post',
responseType: 'json',
data: convertFormData({
db_host: values.db_host,
db_port: values.db_port,
db_id: values.db_id,
db_pw: values.db_pw,
db_name: values.db_name,
serv_host: values.serv_host,
shared_icon_path: values.shared_icon_path,
game_image_path: values.game_image_path,
image_request_key: values.image_request_key,
kakao_rest_key: values.kakao_rest_key,
kakao_admin_key: values.kakao_admin_key,
})
});
result = response.data;
} catch (e) {
console.error(e);
alert(`에러: ${e}`);
return;
}
if (!result.result) {
alert(result.reason);
return;
}
alert('RootDB.php가 생성되었습니다. 관리자 계정 생성을 진행합니다.');
await changeInstallMode();
});
}
function maxStrWidth(maxWidth: number) {
return (rule: unknown, value: string) => {
if (!value || !isString(value) || mb_strwidth(value) > maxWidth) {
return new Error(`글자 너비가 알파벳 ${maxWidth} 자보다 길지 않아야합니다`);
}
return true;
};
}
function setupAdminForm() {
type AdminValues = {
username: string,
password: string,
confirm_password: string,
nickname: string,
}
const descriptor: NamedRules<AdminValues> = {
username: {
required: true,
min: 4,
max: 64,
type: 'string',
options: {//FIXME: options.messages가 동작하지 않는다?
messages: {
required: "유저명을 입력해주세요",
string: {
min: (v, l) => `${l}글자 이상 입력하셔야 합니다`,
max: (v, l) => `${l}자를 넘을 수 없습니다`,
}
}
}
},
password: {
required: true,
type: 'string',
min: 6,
options: {
messages: {
required: "비밀번호를 입력해주세요",
string: {
min: (v, l) => `비밀번호는 적어도 ${l}글자 이상이어야 합니다`
}
}
}
},
confirm_password: {
required: true,
type: 'string',
min: 6,
validator: (rule, value: string, _callback, source) => {
console.log(value);
if (value != (source.password ?? '')) {
return new Error('비밀번호가 일치하지 않습니다');
}
return true;
},
options: {
messages: {
required: "비밀번호를 입력해주세요",
string: {
min: (v, l) => `비밀번호는 적어도 ${l}글자 이상이어야 합니다`
}
}
}
},
nickname: {
required: true,
validator: maxStrWidth(18),
options: {
messages: {
required: "유저명을 입력해주세요",
}
}
},
}
const validator = new JQValidateForm($('#admin_form'), descriptor);
validator.installChangeHandler();
$('#admin_form').on('submit', async function (e) {
e.preventDefault();
const values = await validator.validate();
if (values === undefined) {
return;
}
const raw_password = values.password;
const salt = unwrap_any<string>($('#global_salt').val());
console.log(salt + raw_password + salt);
const hash_pw = sha512(salt + raw_password + salt);
let result: InvalidResponse;
try {
const response = await axios({
url: 'j_create_admin.php',
method: 'post',
responseType: 'json',
data: convertFormData({
username: values.username,
password: hash_pw,
nickname: values.nickname,
})
});
result = response.data;
} catch (e) {
console.error(e);
alert(`에러: ${e}`);
return;
}
if (!result.result) {
alert(result.reason);
return;
}
alert('관리자 계정이 생성되었습니다.');
await changeInstallMode();
});
}
$(function () {
setAxiosXMLHttpRequest();
void changeInstallMode();
setupDBForm();
setupAdminForm();
});
+191
View File
@@ -0,0 +1,191 @@
import $ from 'jquery';
import axios from 'axios';
import { JQValidateForm, type NamedRules } from '@util/jqValidateForm';
import { convertFormData } from '@util/convertFormData';
import type { InvalidResponse } from '@/defs';
import { setAxiosXMLHttpRequest } from '@util/setAxiosXMLHttpRequest';
import { unwrap_any } from '@util/unwrap_any';
import { sha512 } from 'js-sha512';
import { isString } from 'lodash';
import { mb_strwidth } from '@util/mb_strwidth';
import '@/gateway/common';
$(async function () {
setAxiosXMLHttpRequest();
const terms1P = axios('../terms.1.html');
const terms2P = axios('../terms.2.html');
type JoinFormType = {
secret_agree: boolean,
secret_agree2: boolean,
third_use: boolean,
username: string,
password: string,
confirm_password: string,
nickname: string,
}
const descriptor: NamedRules<JoinFormType> = {
username: {
required: true,
min: 4,
max: 64,
type: 'string',
asyncValidator: async (rule, value: string) => {
const response = await axios({
url: 'j_check_dup.php',
method: 'post',
responseType: 'json',
data: convertFormData({
type: 'username',
value: value,
})
});
const isValid: boolean|string = response.data;
if (isString(isValid)) {
throw new Error(isValid);
}
},
options: {//FIXME: options.messages가 동작하지 않는다?
messages: {
required: "유저명을 입력해주세요",
string: {
min: (v, l) => `${l}글자 이상 입력하셔야 합니다`,
max: (v, l) => `${l}자를 넘을 수 없습니다`,
}
}
}
},
password: {
required: true,
type: 'string',
min: 6,
options: {
messages: {
required: "비밀번호를 입력해주세요",
string: {
min: (v, l) => `비밀번호는 적어도 ${l}글자 이상이어야 합니다`
}
}
}
},
confirm_password: {
required: true,
type: 'string',
min: 6,
validator: (rule, value: string, _callback, source) => {
console.log(value);
if (value != (source.password ?? '')) {
return new Error('비밀번호가 일치하지 않습니다');
}
return true;
},
options: {
messages: {
required: "비밀번호를 입력해주세요",
string: {
min: (v, l) => `비밀번호는 적어도 ${l}글자 이상이어야 합니다`
}
}
}
},
nickname: {
required: true,
asyncValidator: async (rule, value: string) => {
if (!value || !isString(value) || mb_strwidth(value) > 18) {
throw new Error(`글자 너비가 알파벳 ${18}자보다 길지 않아야합니다`);
}
const response = await axios({
url: 'j_check_dup.php',
method: 'post',
responseType: 'json',
data: convertFormData({
type: 'nickname',
value: value,
})
});
const isValid: boolean|string = response.data;
if (isString(isValid)) {
throw new Error(isValid);
}
},
options: {
messages: {
required: "유저명을 입력해주세요",
}
}
},
secret_agree: {
required: true,
transform: (v)=>v=='on',
type: 'enum',
enum: [true],
message: '동의해야만 가입하실 수 있습니다.',
},
secret_agree2: {
required: true,
transform: (v)=>v=='on',
type: 'enum',
enum: [true],
message: '동의해야만 가입하실 수 있습니다.',
},
third_use: {
required: true,
transform: (v)=>v=='on',
type: 'boolean'
}
}
const validator = new JQValidateForm($('#main_form'), descriptor);
validator.installChangeHandler();
$("#main_form").on('submit', async function (e) {
e.preventDefault();
const values = await validator.validate();
if (values === undefined) {
return;
}
const raw_password = values.password;
const salt = unwrap_any<string>($('#global_salt').val());
console.log(salt + raw_password + salt);
const hash_pw = sha512(salt + raw_password + salt);
let result: InvalidResponse;
try {
const response = await axios({
url: 'j_join_process.php',
responseType: 'json',
method: 'post',
data: convertFormData({
secret_agree: values.secret_agree,
secret_agree2: values.secret_agree2,
third_use: values.third_use,
username: values.username,
password: hash_pw,
nickname: values.nickname,
})
});
result = response.data;
}
catch (e) {
console.error(e);
alert(`실패했습니다: ${e}`);
return;
}
if (!result.result) {
alert(result.reason);
window.location.href = "../";
return;
}
alert('회원 등록되었습니다.\n첫 로그인 과정에서 인증 코드를 입력하는 것으로 계정이 활성화됩니다.');
window.location.href = "../";
});
$('#terms1').html((await terms1P).data);
$('#terms2').html((await terms2P).data);
});
+381
View File
@@ -0,0 +1,381 @@
import '@scss/gateway/login.scss';
import $ from 'jquery';
import { JQValidateForm, type NamedRules } from '@util/jqValidateForm';
import axios from 'axios';
import { convertFormData } from '@util/convertFormData';
import { setAxiosXMLHttpRequest } from '@util/setAxiosXMLHttpRequest';
import { unwrap_any } from '@util/unwrap_any';
import { sha512 } from 'js-sha512';
import { unwrap } from '@util/unwrap';
import { delay } from '@util/delay';
import { Modal } from 'bootstrap';
import '@/gateway/common';
import { isString } from 'lodash';
import { SammoRootAPI, type InvalidResponse } from '@/SammoRootAPI';
import type { LoginFailed, LoginResponse, LoginResponseWithKakao, OTPResponse } from '@/defs/API/Login';
declare global {
interface Window {
getOAuthToken: (mode: string, scope_list: string[]) => void;
postOAuthResult: (mode: string) => void;
}
}
declare const kakao_oauth_client_id: string;
declare const kakao_oauth_redirect_uri: string;
let modalOTP: Modal | undefined = undefined;
let oauthMode: string | null = null;
const TOKEN_VERSION = 1;
const LOGIN_TOKEN_KEY = 'sammo_login_token';
function regNextToken(tokenInfo: [number, string]) {
localStorage.setItem(LOGIN_TOKEN_KEY, JSON.stringify([TOKEN_VERSION, tokenInfo, Date.now()]));
}
function getToken(): [number, string] | undefined {
const trialToken = localStorage.getItem(LOGIN_TOKEN_KEY);
if (!trialToken) {
return;
}
const tokenItems = JSON.parse(trialToken) as [number, [number, string], string];
if (tokenItems[0] != TOKEN_VERSION) {
console.log(tokenItems);
resetToken();
return;
}
const [, token,] = tokenItems;
return token;
}
function resetToken() {
localStorage.removeItem(LOGIN_TOKEN_KEY);
}
async function tryAutoLogin() {
try {
const tokenInfo = getToken();
if (!tokenInfo) {
return;
}
const [tokenID, token] = tokenInfo;
const result = await SammoRootAPI.Login.ReqNonce(undefined, true);
if (!result) {
//api 에러.
return;
}
if (!result.result) {
resetToken();
return;
}
const nonce = result.loginNonce;
const hashedToken = sha512(token + nonce);
const loginResult = await SammoRootAPI.Login.LoginByToken({
'hashedToken': hashedToken,
'token_id': tokenID,
}, true);
if (!loginResult.result) {
if (!loginResult.silent) {
alert(loginResult.reason);
}
console.error(loginResult.reason);
return;
}
if (loginResult.nextToken) {
regNextToken(loginResult.nextToken);
}
window.location.href = "./";
}
catch (e) {
if (isString(e)) {
alert(e);
}
console.error(e);
return;
}
}
function getOAuthToken(mode: string, scope_list?: string[] | string) {
if (mode === undefined) {
mode = 'login';
}
oauthMode = mode;
let url = `https://kauth.kakao.com/oauth/authorize?client_id=${kakao_oauth_client_id}&redirect_uri=${kakao_oauth_redirect_uri}&response_type=code`;
if (Array.isArray(scope_list)) {
url += `&scope=${scope_list.join(',')}`;
} else if (typeof scope_list === 'string') {
url += `&scope=${scope_list}`;
}
window.open(url, "KakaoAccountLogin", "width=600,height=450,resizable=yes,scrollbars=yes");
}
window.getOAuthToken = getOAuthToken;
async function sendTempPasswordToKakaoTalk(): Promise<void> {
let result: InvalidResponse;
try {
const response = await axios({
url: 'oauth_kakao/j_login_oauth.php',
responseType: 'json',
method: 'post',
});
result = response.data;
}
catch (e) {
console.error(e);
alert(`실패했습니다_login: ${e}`);
return;
}
if (!result.result) {
alert(result.reason);
return;
}
try {
const response = await axios({
url: 'oauth_kakao/j_change_pw.php',
responseType: 'json',
method: 'post',
});
result = response.data;
}
catch (e) {
console.error(e);
alert(`실패했습니다_pw: ${e}`);
return;
}
alert('임시 비밀번호가 카카오톡으로 전송되었습니다.');
}
async function doLoginUsingOAuth() {
let result: LoginResponseWithKakao;
try {
const response = await axios({
url: 'oauth_kakao/j_login_oauth.php',
responseType: 'json',
method: 'post',
});
result = response.data;
}
catch (e) {
console.error(e);
alert(`실패했습니다_login: ${e}`);
return;
}
if (result.result) {
if (result.nextToken) {
regNextToken(result.nextToken);
}
window.location.href = "./";
return;
}
if (!result.reqOTP) {
alert(result.reason);
return;
}
const modalEl = unwrap(document.querySelector('#modalOTP'))
if (!modalOTP) {
modalOTP = new Modal(modalEl);
modalEl.addEventListener('shown.bs.modal', function () {
unwrap(document.querySelector<HTMLElement>('#otp_code')).focus();
});
}
modalOTP.show();
}
function postOAuthResult(mode: string) {
if (mode == 'join') {
window.location.href = 'oauth_kakao/join.php';
return;
}
if (mode == 'req_email') {
alert('이메일 정보 공유를 허가해 주셔야 합니다.');
return;
}
if (mode == 'login') {
console.log('로그인모드');
if (oauthMode == 'change_pw') {
void sendTempPasswordToKakaoTalk();
} else {
void doLoginUsingOAuth();
}
return;
}
alert('예외 발생!');
}
window.postOAuthResult = postOAuthResult;
$(async function ($) {
setAxiosXMLHttpRequest();
//로그인 먼저 해볼 것
if (getToken()) {
void tryAutoLogin();
await delay(100);
}
type LoginFormType = {
username: string,
password: string,
};
const descriptor: NamedRules<LoginFormType> = {
username: {
required: true,
type: 'string',
message: '유저명을 입력해주세요',
},
password: {
required: true,
type: 'string',
message: '비밀번호를 입력해주세요'
}
};
const validator = new JQValidateForm($('#main_form'), descriptor);
validator.installChangeHandler();
$("#main_form").on('submit', async function (e) {
e.preventDefault();
const values = await validator.validate();
if (values === undefined) {
return;
}
const raw_password = values.password;
const salt = unwrap_any<string>($('#global_salt').val());
const hash_pw = sha512(salt + raw_password + salt);
let result: LoginResponse | LoginFailed;
try {
result = await SammoRootAPI.Login.LoginByID({
username: values.username,
password: hash_pw,
}, true);
}
catch (e) {
console.error(e);
alert(`실패했습니다: ${e}`);
return;
}
if (result.result) {
if (result.nextToken) {
regNextToken(result.nextToken);
}
window.location.href = "./";
return;
}
if (!result.reqOTP) {
alert(result.reason);
return;
}
const modalEl = unwrap(document.querySelector('#modalOTP'))
if (!modalOTP) {
modalOTP = new Modal(modalEl);
modalEl.addEventListener('shown.bs.modal', function () {
unwrap(document.querySelector<HTMLElement>('#otp_code')).focus();
});
}
modalOTP.show();
});
$('#otp_form').on('submit', async function (e) {
e.preventDefault();
let result: OTPResponse;
try {
const response = await axios({
url: 'oauth_kakao/j_check_OTP.php',
responseType: 'json',
method: 'post',
data: convertFormData({
otp: unwrap_any<string>($('#otp_code').val()),
})
});
result = response.data;
}
catch (e) {
console.error(e);
alert(`실패했습니다: ${e}`);
return;
}
if (result.result) {
alert(`로그인되었습니다. ${result.validUntil}까지 유효합니다.`);
window.location.href = "./";
return;
}
alert(result.reason);
if (result.reset) {
if (modalOTP) {
modalOTP.hide();
}
return;
}
});
$('#oauth_change_pw').on('click', function (e) {
e.preventDefault();
getOAuthToken('change_pw', 'talk_message');
});
$('#btn_kakao_login').on('click', function (e) {
e.preventDefault();
getOAuthToken('login', ['account_email', 'talk_message']);
})
//TODO: 모바일에서 크기 비례 지도 다시 띄우기?
/*
if (document.body.clientWidth < 700) {
const targetWidth = document.body.clientWidth * 0.9;
const scale = targetWidth / 700;
const $map = $('#running_map');
$map.find('.col').css('max-width', targetWidth);
$map.find('.card').css('width', targetWidth);
$map.find('.map-container').css({
'transform-origin': 'top left',
'transform': `scale(${scale}, ${scale})`,
'height': 500 * scale,
});
$map.find('.map_body').data('scale', scale);
}*/
});
window.fitIframe = function () {
const iframe = unwrap(document.querySelector<HTMLIFrameElement>('#running_map'));//TODO: 근황 여러개 볼 수 있도록?
const scrollHeight = unwrap(iframe.contentWindow).document.body.scrollHeight;
iframe.style.height = `${scrollHeight}px`;
}
+384
View File
@@ -0,0 +1,384 @@
import { setAxiosXMLHttpRequest } from '@util/setAxiosXMLHttpRequest';
import $ from 'jquery';
import axios from 'axios';
import { subDays } from 'date-fns';
import { getDateTimeNow } from '@util/getDateTimeNow';
import { sha512 } from "js-sha512";
import { convertFormData } from '@util/convertFormData';
import type { InvalidResponse } from '@/defs';
import { unwrap } from '@util/unwrap';
import { parseTime } from '@util/parseTime';
import { formatTime } from '@util/formatTime';
import '@/gateway/common';
import { Modal } from 'bootstrap';
type ResultUserInfo = {
result: true,
id: number,
name: string,
grade: string,
picture: string,
global_salt: string,
join_date: string,
third_use: boolean,
acl: string,
oauth_type: 'NONE' | 'KAKAO',
token_valid_until?: string
}
function fillUserInfo(result: ResultUserInfo | InvalidResponse) {
if (!result.result) {
alert(result.reason);
location.href = 'entrance.php';
return;
}
$('#slot_id').html(result.id.toString());
$('#slot_nickname').html(result.name);
$('#slot_grade').html(result.grade);
$('#slot_acl').html(result.acl);
$('#slot_icon').attr('src', result.picture);
$('#global_salt').val(result.global_salt);
$('#slot_join_date').html(result.join_date);
$('#slot_third_use').html(result.third_use ? '○' : '×');
if (result.third_use) {
$('#third_use_disallow').show();
}
$('#slot_oauth_type').text(result.oauth_type);
if (result.oauth_type != 'NONE') {
$('#slot_token_valid_until').text(unwrap(result.token_valid_until));
}
else {
$('#slot_token_valid_until').parent().html('');
}
}
function changeIconPreview(this: HTMLFormElement) {
const $preview = $(this) as JQuery<HTMLFormElement>;
console.log($preview);
const filename = $preview[0].files[0].name;
const reader = new FileReader();
reader.onload = function (e) {
$('#slot_new_icon').attr('src', unwrap(unwrap(e.target).result).toString()).css('visibility', 'visible');
}
reader.readAsDataURL($preview[0].files[0]);
$('#image_upload_filename').val(filename);
}
type IconResponse = {
result: true,
servers: [string, string][]
};
async function deleteIcon() {
let result: InvalidResponse | IconResponse;
try {
const response = await axios({
url: 'j_icon_delete.php',
responseType: 'json',
method: 'post'
});
result = response.data;
}
catch (e) {
console.error(e);
alert(`아이콘 삭제를 실패했습니다: ${e}`);
return;
}
if (!result.result) {
alert(result.reason);
location.reload();
return;
}
showAdjustServerModal(result.servers);
}
async function disallowThirdUse() {
try {
await axios({
url: 'j_disallow_third_use.php',
method: 'post',
responseType: 'json'
});
alert('철회했습니다.');
}
catch (e) {
alert('알 수 없는 이유로 철회를 실패했습니다.');
}
location.reload();
}
function showAdjustServerModal(serverList: [string, string][]) {
const $form = $('#chooseServerForm');
$form.empty();
for (const [serverKey, serverKorName] of serverList) {
const $item = $(`<div style="display:inline-block;margin-right:7px;" class="custom-control custom-checkbox">\
<input type="checkbox" checked class="custom-control-input" name="${serverKey}" id="switch_${serverKey}">\
<label class="custom-control-label" for="switch_${serverKey}">${serverKorName}</label>\
</div>`);
$form.append($item);
}
const modalEl = unwrap(document.querySelector('#chooseServer'));
const modal = new Modal(modalEl, {
backdrop: 'static'
});
modalEl.addEventListener('hidden.bs.modal', function () {
location.reload();
return;
});
modal.show();
$("#modal-apply").off("click").on("click", async function () {
const events: Promise<unknown>[] = [];
$form.find('input:checked').each(function () {
const $input = $(this);
const server = $input.attr('name');
console.log(server);
events.push(axios({
url: `../${server}/j_adjust_icon.php`,
method: 'post',
responseType: 'json',
}));
})
for (const p of events) {
try {
await p;
}
catch (e) {
//서버 폐쇄등으로 접근하지 못할 수도 있음.
console.error(e, p);
}
}
alert('적용되었습니다.');
location.reload();
});
}
async function changeIcon(this: HTMLFormElement) {
const $icon = $('#image_upload') as JQuery<HTMLFormElement>;
if ($icon[0].files.length == 0) {
alert('파일을 선택해주세요');
return false;
}
let result: InvalidResponse | IconResponse;
try {
const response = await axios({
url: 'j_icon_change.php',
method: 'post',
responseType: 'json',
data: new FormData(this)
});
result = response.data;
}
catch (e) {
alert('알 수 없는 이유로 아이콘 업로드를 실패했습니다.');
location.reload();
return;
}
if (!result.result) {
alert(result.reason);
location.reload();
return;
}
showAdjustServerModal(result.servers);
}
async function changePassword() {
const old_pw = $('#current_pw').val() as string;
const new_pw = $('#new_pw').val() as string;
const new_pw_confirm = $('#new_pw_confirm').val() as string;
if (!old_pw) {
alert('이전 비밀번호를 입력해야 합니다.');
return;
}
if (new_pw.length < 6) {
alert('비밀번호 길이는 6글자 이상이어야 합니다.');
return;
}
if (new_pw != new_pw_confirm) {
alert('입력 값이 일치하지 않습니다.');
return;
}
const global_salt = $('#global_salt').val();
const old_password = sha512(global_salt + old_pw + global_salt);
const new_password = sha512(global_salt + new_pw + global_salt);
let result: InvalidResponse;
try {
const response = await axios({
url: 'j_change_password.php',
method: 'post',
responseType: 'json',
data: convertFormData({
old_pw: old_password,
new_pw: new_password
})
});
result = response.data;
}
catch (e) {
console.error(e);
alert(`알 수 없는 이유로 비밀번호를 바꾸지 못했습니다.: ${e}`);
return;
}
if (!result.result) {
alert(result.reason);
return;
}
alert('비밀번호를 바꾸었습니다');
location.reload();
}
async function deleteMe() {
const pw = $('#delete_pw').val() as string;
if (!pw) {
alert('비밀번호를 입력해야 합니다.');
return;
}
const global_salt = $('#global_salt').val();
const password = sha512(global_salt + pw + global_salt);
let data: InvalidResponse;
try {
const response = await axios({
url: 'j_delete_me.php',
responseType: 'json',
method: 'post',
data: convertFormData({
pw: password
})
});
data = response.data;
}
catch (e) {
console.error(e);
alert(`회원 탈퇴에 실패했습니다.: ${e}`);
return;
}
if (!data.result) {
alert(data.reason);
return;
}
alert('탈퇴 처리되었습니다.');
location.href = '../';
}
async function extendAuth() {
const validUntil = $('#slot_token_valid_until').html();
const availableAt = formatTime(subDays(parseTime(validUntil), 5));
const now = getDateTimeNow();
if (now < availableAt) {
alert(`${availableAt}부터 초기화할 수 있습니다.`);
return false;
}
if (!confirm('로그아웃됩니다. 진행할까요?')) {
return;
}
let result: InvalidResponse;
try {
const response = await axios({
url: '../oauth_kakao/j_reset_token.php',
method: 'post',
responseType: 'json'
})
result = response.data;
} catch (e) {
alert(`알 수 없는 이유로 로그인 토큰 연장에 실패했습니다. : ${e}`);
return;
}
if (!result.result) {
alert(result.reason);
return;
}
alert('초기화했습니다. 다시 로그인해 주십시오.');
location.href = '../';
}
$(function () {
setAxiosXMLHttpRequest();
$('#slot_icon, #slot_new_icon').attr('src', window.pathConfig.sharedIcon + '/default.jpg');
axios({
url: 'j_get_user_info.php',
method: 'post',
responseType: 'json'
}).then(result => {
fillUserInfo(result.data);
}, () => {
alert('알 수 없는 이유로, 회원 정보를 불러오지 못했습니다.');
location.href = 'entrance.php';
})
$('#image_upload').on('change', changeIconPreview);
$('#btn_remove_icon').on('click', function () {
if (confirm('아이콘을 제거할까요?')) {
void deleteIcon();
}
return false;
});
$('#third_use_disallow').on('click', function () {
if (confirm('개인정보 3자 제공 동의를 철회할까요?')) {
void disallowThirdUse();
}
});
$('#change_pw_form').on('submit', function (e) {
e.preventDefault();
void changePassword();
});
$('#change_icon_form').on('submit', function (e) {
e.preventDefault();
void changeIcon.apply(this as HTMLFormElement);
});
$('#delete_me_form').on('submit', function (e) {
e.preventDefault();
if (confirm('한 달 동안 재 가입할 수 없습니다. 정말로 탈퇴할까요?')) {
void deleteMe();
}
});
$('#expand_login_token').on('click', extendAuth);
})
@@ -0,0 +1,59 @@
<template>
<div>
<div v-for="(rowValue, rowIdx) in props.params.cells" :key="rowIdx" class="row gx-1 m-0 cell-sp">
<template v-for="colValue of rowValue" :key="colValue.target">
<div v-if="params.data[colValue.target] == null">?</div>
<div
v-else-if="colValue.iActionMap"
v-b-tooltip.hover
class="col"
:title="colValue.iActionMap[params.data[colValue.target] as string ].info??''"
>
{{ colValue.iActionMap[params.data[colValue.target] as string ].name }}
</div>
<div
v-else-if="colValue.converter"
v-b-tooltip.hover
class="col"
:title="colValue.converter(params.data)[1] ?? ''"
>
{{ colValue.converter(params.data)[0] }}
</div>
<div v-else v-b-tooltip.hover class="col" :title="colValue.info ?? ''">
{{params.data[colValue.target] as string}}
</div>
</template>
</div>
</div>
</template>
<script lang="ts">
import type { GeneralListItemP2 } from "@/defs/API/Nation";
import type { GameIActionInfo } from "@/defs/GameObj";
import type { CellClassParams } from "ag-grid-community";
//제일 큰 타입 기준
export type GridCellInfo = {
iActionMap?: Record<string, GameIActionInfo>;
info?: string;
converter?: (value: GeneralListItemP2) => [string, string?];
target: keyof GeneralListItemP2;
};
</script>
<script lang="ts" setup>
import type { PropType } from "vue";
interface GenCellClassParams extends CellClassParams {
data: GeneralListItemP2;
}
const props = defineProps({
params: {
type: Object as PropType<
GenCellClassParams & {
cells: GridCellInfo[][];
}
>,
required: true,
},
});
</script>
@@ -0,0 +1,45 @@
<template>
<span v-if="props.params.value == null">?</span>
<span v-else-if="params.iActionMap" v-b-tooltip.hover :title="params.iActionMap[props.params.value].info ?? ''">{{
params.iActionMap[props.params.value].name
}}</span>
<span v-else-if="params.info" v-b-tooltip.hover :title="params.info">{{ displayValue }}</span>
<span v-else>{{ displayValue }}</span>
</template>
<script lang="ts" setup>
import type { GameIActionInfo } from "@/defs/GameObj";
import type { ValueFormatterParams } from "ag-grid-community";
import { isNumber, isString } from "lodash";
import { ref, watch, type PropType } from "vue";
const props = defineProps({
params: {
type: Object as PropType<
ValueFormatterParams & {
info?: string;
iActionMap?: Record<string, GameIActionInfo>;
}
>,
required: true,
},
});
function convertValue(value: unknown): string {
if (isString(value)) {
return value;
}
if (isNumber(value)) {
return value.toLocaleString();
}
return `${value}`;
}
const displayValue = ref<string>(convertValue(props.params.value));
watch(
() => props.params,
(newParams) => {
displayValue.value = convertValue(newParams.value);
}
);
</script>
+29
View File
@@ -0,0 +1,29 @@
import "@scss/hallOfFame.scss";
import $ from 'jquery';
import { stringifyUrl } from 'query-string';
import { initTooltip } from "@/legacy/initTooltip";
import { auto500px } from './util/auto500px';
import { htmlReady } from './util/htmlReady';
import { insertCustomCSS } from './util/customCSS';
auto500px();
htmlReady(() => {
$('#by_scenario').on('change', function (e) {
e.preventDefault();
const $this = $(this);
const scenarioIdx = $this.val();
const seasonIdx = $(this).find('option:selected').data('season');
document.location.href = stringifyUrl({
url: 'a_hallOfFame.php',
query: {
scenarioIdx: scenarioIdx, seasonIdx: seasonIdx
}
});
})
initTooltip();
insertCustomCSS();
});
+70
View File
@@ -0,0 +1,70 @@
import type { NPCChiefActions, NPCGeneralActions } from "@/defs";
export const NPCPriorityBtnHelpMessage: {
[v in NPCChiefActions | NPCGeneralActions]: string;
} &
Record<string, string> = {
:
"군주가 NPC이고, 타국에서 원조를 받았을 때,<br>세입(금/쌀) 대비 원조량에 따라 불가침제의를 합니다.",
:
"군주가 NPC이고, 전쟁중이 아닐 때,<br>주변국중 하나를 골라 선포합니다.<br><br>선포 시점은 다음을 참고합니다.<br>- 인구율<br>- 도시내정률<br>- NPC전투장권장 금 충족률<br>- NPC전투장권장 쌀 충족률<br><br>국력이 낮은 국가를 조금 더 선호합니다.",
: "인구가 많은 곳을 찾아 천도를 시도합니다.<br>영토의 가운데를 선호합니다.<br><br>도시 인구가 충분하다면, 굳이 천도하지는 않습니다.",
:
"금/쌀이 부족한 유저전투장에게 긴급하게 포상합니다.<br>국고가 권장량보다 적어지더라도 시도합니다.",
:
"(작동하지 않음)<br>전투 부대를 접경으로 발령합니다.<br>수도->시작점->도착점 경로를 따릅니다.",
:
"아군 영토에 있지 않은 유저장을 아군 영토로 발령합니다.<br>곧 집합하는 부대에 탑승한 경우는 제외합니다.",
:
"유저전투장 중에<br>- 병력이 충분하지 않고,<br>- 도시의 인구가 제자리 징병할 수 있을 정도로 충분하지 않고,<br>- 부대에 탑승하지 않았다면,<br>인구가 충분한 후방도시로 발령합니다.",
:
"접경에 위치한 부대에 탑승한 유저전투장 중에,<br>- 병력이 충분하지 않고,<br>- 첫 턴이 징병턴이며,<br>- 부대장 집합 턴 사이라면,<br>인구가 충분한 후방도시로 발령합니다.<br><br>부대장의 위치와 유저장의 위치가 다르다면 발령하지 않습니다.",
:
"후방에 있는 유저장이<br>- 병력을 가지고 있으며,<br>- 곧 훈련/사기진작이 완료될 것 같으면,<br>전방으로 발령합니다.<br><br>도시 관직이 많이 임명된 도시를 선호합니다.",
:
"금/쌀이 부족한 유저장에게 포상합니다.<br>유저전투장과 유저내정장은 각각 기준을 따릅니다.<br>국고 권장량을 가급적 지킵니다.",
:
"(작동하지 않음)<br>후방 부대가 위치한 도시의 인구가 충분하지 않을 경우,<br>인구가 충분한 도시로 발령합니다.",
:
"전투 부대, 후방 부대가 아닌 부대가 아군 영토에 있지 않을 때,<br>전방 도시 중 하나를 골라 발령합니다.",
NPC긴급포상:
"금/쌀이 부족한 NPC전투장에게 긴급하게 포상합니다.<br>국고가 권장량보다 '약간' 적어지더라도 시도합니다.",
NPC구출발령: "아군 영토에 있지 않은 NPC장을 아군 영토로 발령합니다.",
NPC후방발령:
"NPC전투장 중에<br>- 병력이 충분하지 않고,<br>- 도시의 인구가 제자리 징병할 수 있을 정도로 충분하지 않고,<br>- 부대에 탑승하지 않았다면,<br>인구가 충분한 후방도시로 발령합니다.",
NPC포상:
"금/쌀이 부족한 NPC에게 포상합니다.<br>NPC전투장과 NPC내정장은 각각 기준을 따릅니다.<br>국고 권장량을 가급적 지킵니다.",
NPC전방발령:
"후방에 있는 유저장이<br>- 병력을 가지고 있으며,<br>- 곧 훈련/사기진작이 완료될 것 같으면,<br>전방으로 발령합니다.<br><br>도시 관직이 많이 임명된 도시를 선호합니다.",
:
"내정중인 유저장이 위치한 도시의 내정률이 95% 이상이면<br>개발되지 않은 도시로 발령합니다.",
NPC내정발령:
"내정중인 NPC장이 위치한 도시의 내정률이 95% 이상이면<br>개발되지 않은 도시로 발령합니다.",
NPC몰수:
"국고가 부족하다면 NPC에게서 몰수합니다. 내정NPC장은 국고가 부족하지 않아도 몰수합니다.",
NPC사망대비:
"NPC의 사망까지 5턴 이내인 경우, 헌납합니다.<br>헌납할 금쌀이 없다면 물자조달을 수행합니다.",
: "아국 도시에 있지 않다면 귀환합니다.",
:
"전쟁 중에 금쌀의 비율이 크게 차이난다면 금쌀을 거래하여 비슷하게 맞춥니다.<br>금쌀 비율이 적절하는지 판단하는데 살상률을 포함합니다.<br>NPC는 상인이 없어도 금쌀을 구매할 수 있습니다.<br><br>또는 금쌀 한쪽이 지나치게 적은 경우에는 내정 중에도 금쌀을 거래합니다.",
: "충분한 병력과 충분한 훈련/사기를 가지고 있는 경우 출병합니다.<br>접경이 여럿인 경우 무작위로 선택합니다.<br><br>타국과 전쟁중인 경우 공백지로는 출병하지 않습니다.",
:
"전쟁중에 민심이 70 미만이거나,<br>인구가 제자리 징병이 가능하지 않을 정도로 적을 경우,<br>일정확률로 주민선정과 정착장려를 수행합니다.<br><br>통솔이 높을 수록 수행할 확률이 높습니다.",
:
"충분한 병력을 가지고 있지만 훈련과 사기가 부족한 경우 훈련과 사기진작을 수행합니다.",
: "전투장이 충분한 병력을 가지고 있다면 전방으로 이동합니다.",
NPC헌납:
"국고가 부족한데 NPC전쟁장이 충분한 금쌀(권장량 대비 1.5배)을 가지고 있다면 일부를 헌납합니다. <br>NPC내정장은 국고가 넉넉하더라도 충분한 금쌀을 가지고 있다면 권장량만 유지하고 헌납합니다.",
: "전쟁 중 병력을 소진하였다면 재 징병합니다.<br><br>기존에 사용한 병종군 중에서 사용가능한 병종을 랜덤하게 선택합니다.<br>고급 병종을 선택할 확률이 조금 더 높습니다.<br><br>NPC의 경우 도시의 인구가 충분하지 않다면 징병을 할 확률이 감소합니다.<br><br>유저장은 최대한 고급병종을 유지하며,<br>유저장 모병이 허용되는 경우 모병을 3회할 수 있다면 모병합니다.",
:
"전쟁 중 병력을 소진하였는데 도시의 인구가 충분하지 않다면,<br>인구가 많은 도시로 이동합니다.",
:
"전쟁 중 수행하는 내정입니다.<br>정착장려, 기술연구의 확률이 좀 더 높고,<br>치안강화, 농지개간, 상업투자의 확률이 낮습니다.<br><br>내정이 가능하다 하더라도 전시임을 고려해,<br>30% 확률로 다른 턴을 수행합니다.",
:
"전쟁 중이 아닌 데 병력이 남아있는 경우,<br>3/4 확률로 소집해제합니다.",
:
"도시에서 내정을 수행합니다. 낮은 내정일 수록 수행할 확률이 높습니다.<br>기술 연구는 1등급 이상 뒤쳐지지 않도록 노력합니다.",
:
"도시에서 더이상 내정을 수행할 수 없는 경우,<br>일정확률로 내정이 부족한 다른 도시로 이동합니다.",
};
+44
View File
@@ -0,0 +1,44 @@
import $ from 'jquery';
import '@/map';
import { joinYearMonth } from './util/joinYearMonth';
import { parseYearMonth } from './util/parseYearMonth';
declare const staticValues: {
startYear:number;
startMonth:number;
lastYear: number;
lastMonth: number;
selectYear: number;
selectMonth: number;
}
$(function ($) {
let currYear = staticValues.startYear;
let currMonth = staticValues.startMonth;
const selectDate = joinYearMonth(staticValues.selectYear, staticValues.selectMonth);
const $yearMonth = $('#yearmonth');
let $elements = $();
const endDate = joinYearMonth(staticValues.lastYear, staticValues.lastMonth) + 1;//연감 마지막 + 1(현재)
let currDate = joinYearMonth(currYear, currMonth);
while (currDate <= endDate) {
let sel = '';
if (currDate == selectDate) {
sel = 'selected="selected"';
}
const more = currDate == endDate? ' (현재)':'';
const option = $(`<option value="${currDate}" ${sel} >${currYear}${currMonth}${more}</option>`);
$elements = $elements.add(option);
currDate += 1;
[currYear, currMonth] = parseYearMonth(currDate);
}
$yearMonth.empty();
$yearMonth.append($elements);
});
+269
View File
@@ -0,0 +1,269 @@
import $ from "jquery";
import 'bootstrap';
import { setAxiosXMLHttpRequest } from "@util/setAxiosXMLHttpRequest";
import axios from "axios";
import type { InvalidResponse } from "@/defs";
import { JQValidateForm, type NamedRules } from "@util/jqValidateForm";
import { convertFormData } from "@util/convertFormData";
import { exportWindow } from "@util/exportWindow";
type ResponseScenarioItem = {
year?: number,
title: string,
npc_cnt: number,
npcEx_cnt: number,
npcNeutral_cnt: number,
nation: Record<number, {
id: number,
name: string,
color: string,
gold: number,
rice: number,
infoText: string,
tech: number,
type: string,
nationLevel: number,
cities: string[],
generals: number,
generalsEx: number,
generalsNeutral: number
}>
}
type ExpandedScenarioItem = ResponseScenarioItem & {
category: string,
idx: string,
year: number,
}
type ResponseScenario = {
result: true,
scenario: Record<number, ResponseScenarioItem>
}
async function loadScenarios() {
let result: InvalidResponse | ResponseScenario;
try {
const response = await axios({
url: 'j_load_scenarios.php',
method: 'post',
responseType: 'json'
});
result = response.data;
}
catch (e) {
alert(`시나리오를 불러오는데 실패했습니다.: ${e}`);
return;
}
if (!result.result) {
alert(result.reason);
return;
}
const list: Record<string, Record<string, ExpandedScenarioItem>> = {};
const pat = /【(.*?)[0-9\-_.a-zA-Z]*】/;
for (const [idx, value] of Object.entries(result.scenario)) {
const title = value.title || "-";
const categoryRes = pat.exec(title);
const category = categoryRes ? categoryRes[1] : '-';
if (!(category in list)) {
list[category] = {};
}
list[category][idx] = {
...value,
title: title,
category: category,
idx: idx,
year: value.year ?? 180
};
}
const $select = $('#scenario_sel');
for (const [category, items] of Object.entries(list)) {
const $optgroup = $('<optgroup>').attr('label', category);
for (const [idx, scenario] of Object.entries(items)) {
const $option = $('<option>')
.data('scenario', scenario)
.val(idx)
.html(scenario.title);
$optgroup.append($option);
}
$select.append($optgroup);
}
$select.val(0);
$select.trigger('change');
}
function scenarioPreview() {
const $select = $('#scenario_sel');
const $option = $select.find('option:selected');
const $year = $('#scenario_begin');
const $npc = $('#scenario_npc');
const $npcEx = $('#scenario_npc_extend');
const $nation = $('#scenario_nation');
const scenario = $option.data('scenario') as ExpandedScenarioItem;
console.log(scenario.idx, scenario.title);
$year.html(`${scenario.year}`);
$npc.html(`${scenario.npc_cnt}`);
if (scenario.npcEx_cnt == 0) {
$npcEx.html('');
} else {
$npcEx.html(`+${scenario.npcEx_cnt}`);
}
$nation.html('');
$.each(scenario.nation, function (idx, nation) {
$nation.append(`<span style="color:${nation.color}">${nation.name}</span> ${nation.generals}명. ${nation.cities.join(', ')}<br>`);
});
}
type InstallFormType = {
turnterm: number,
sync: 1|0,
scenario: number,
fiction: number,
extend: number,
npcmode: number,
show_img_level: number,
tournament_trig: number,
join_mode: number,
autorun_user: string[],
autorun_user_minutes: number,
reserve_open?: string,
pre_reserve_open?: string,
}
const descriptor: NamedRules<InstallFormType> = {
turnterm: {
required: true,
type: "enum",
enum: [1, 2, 5, 10, 20, 30, 60, 120],
transform: parseInt,
},
sync: {
required: true,
type: "enum",
enum: [1, 0],
transform: parseInt,
},
scenario: {
required: true,
},
fiction: {
required: true,
type: "integer",
transform: parseInt,
},
extend: {
required: true,
type: "integer",
transform: parseInt,
},
npcmode: {
required: true,
type: "integer",
transform: parseInt,
},
show_img_level: {
required: true,
type: "integer",
transform: parseInt,
},
tournament_trig: {
required: true,
type: "integer",
transform: parseInt,
},
join_mode: {
required: true,
type: "enum",
enum: ["full", "onlyRandom"]
},
autorun_user: {
type: 'array',
required: false,
defaultField: {
type: 'enum',
enum: ['develop', 'warp', 'recruit', 'recruit_high', 'train', 'battle', 'chief']
},
},
autorun_user_minutes: {
type: 'integer',
required: true,
transform: parseInt,
min: 0,
validator: (rule, value, _callback, source) => {
if(value == 0 && !source.autorun_user?.length){
return true;
}
if(value != 0 && source.autorun_user?.length){
return true;
}
return new Error('유효 시간과 옵션은 동시에 설정해야합니다.');
}
},
reserve_open: {
type: 'pattern',
pattern: new RegExp(/\d{4,4}-\d{2,2}-\d{2,2} \d{2,2}:\d{2,2}/)
},
pre_reserve_open: {
type: 'pattern',
pattern: new RegExp(/\d{4,4}-\d{2,2}-\d{2,2} \d{2,2}:\d{2,2}/)
}
};
function formSetup() {
const validator = new JQValidateForm($('#game_form'), descriptor);
validator.installChangeHandler();
$('#game_form').on('submit', async function (e) {
e.preventDefault();
const values = await validator.validate();
if (values === undefined) {
return;
}
let result: InvalidResponse;
try {
const response = await axios({
url: 'j_install.php',
method: 'post',
responseType: 'json',
data: convertFormData(values)
});
result = response.data;
}
catch (e) {
alert(`알수 없는 에러: ${e}`);
return;
}
if (!result.result) {
alert(result.reason);
return;
}
alert('게임이 리셋되었습니다.');
location.href = '..';
});
}
$(async function () {
setAxiosXMLHttpRequest();
void loadScenarios();
$('#scenario_sel').on('change', scenarioPreview);
formSetup();
})
exportWindow($, '$');
+84
View File
@@ -0,0 +1,84 @@
import axios from 'axios';
import jQuery from 'jquery';
import { JQValidateForm, type NamedRules } from '@util/jqValidateForm';
import { convertFormData } from '@util/convertFormData';
import type { InvalidResponse } from '@/defs';
import { setAxiosXMLHttpRequest } from '@util/setAxiosXMLHttpRequest';
jQuery(async function ($) {
setAxiosXMLHttpRequest();
type FormValue = {
full_reset: string,
db_host: string,
db_port: number,
db_id: string,
db_pw: string,
db_name: string,
}
const descriptor: NamedRules<FormValue> = {
full_reset: {
required: true,
},
db_host: {
type: 'string',
required: true,
},
db_port: {
type: 'integer',
transform: parseInt,
validator: (rule, value: number) => {
if (value <= 0 || value >= 65535) {
return new Error('올바른 포트 범위가 아닙니다.');
}
return true;
}
},
db_id: {
type: 'string',
required: true,
},
db_pw: {
type: 'string',
required: true,
},
db_name: {
type: 'string',
required: true,
}
};
const validator = new JQValidateForm($('#db_form'), descriptor);
validator.installChangeHandler();
$('#db_form').on('submit', async function (e) {
e.preventDefault();
const items = await validator.validate();
if(items === undefined){
return;
}
let data: InvalidResponse;
try{
const response = await axios({
url: 'j_install_db.php',
method: 'post',
responseType: 'json',
data: convertFormData(items)
})
data = response.data as InvalidResponse;
}
catch(e){
alert(e);
return false;
}
if(!data.result){
alert(`에러: ${data.reason}`);
return false;
}
alert('DB.php가 생성되었습니다.');
location.href = 'install.php';
});
});
+42
View File
@@ -0,0 +1,42 @@
import $ from "jquery";
export function activateFlip($obj?: JQuery<HTMLElement>): void {
let $result: JQuery<HTMLElement>;
if ($obj === undefined) {
$result = $('img[data-flip]');
} else {
$result = $obj.find('img[data-flip]');
}
$result.each(function () {
activeFlipItem($(this));
});
}
export function activeFlipItem($img: JQuery<HTMLElement>): void {
const imageList = [];
imageList.push($img.attr('src'));
$.each($img.data('flip').split(','), function (idx, value) {
value = $.trim(value);
if (!value) {
return true;
}
imageList.push(value);
});
if (imageList.length <= 1) {
return;
}
$img.data('computed_flip_array', imageList);
$img.data('computed_flip_idx', 0);
$img.click(function () {
const arr = $img.data('computed_flip_array');
let idx = $img.data('computed_flip_idx');
idx = (idx + 1) % (arr.length);
$img.attr('src', arr[idx]);
$img.data('computed_flip_idx', idx);
});
$img.css('cursor', 'pointer');
}
+19
View File
@@ -0,0 +1,19 @@
/**
* <>& 등을 html에서도 그대로 보이도록 escape주는 함수
* @see https://stackoverflow.com/questions/24816/escaping-html-strings-with-jquery
*/
const entityMap: { [v: string]: string } = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
'/': '&#x2F;',
'`': '&#x60;',
'=': '&#x3D;'
};
export function escapeHtml(string: string): string{
return String(string).replace(/[&<>"'`=/]/g, function (s: string) {
return entityMap[s];
});
}
+46
View File
@@ -0,0 +1,46 @@
import Tooltip from "bootstrap/js/dist/tooltip";
//HACK: 이유는 잘 모르겠지만 bootstrap-vue3에서 bootstrap 호출하는 것과 충돌하여 우회 중
import $ from "jquery";
import { trim } from "lodash";
export function initTooltip($obj?: JQuery<HTMLElement>): void {
if ($obj === undefined) {
$obj = $('.obj_tooltip');
} else if (!$obj.hasClass('obj_tooltip')) {
$obj = $obj.find('.obj_tooltip');
}
$obj.each(function () {
const $target = $(this);
if ($target.data('installHandler')) {
return;
}
$target.data('installHandler', true);
$target.on('mouseover', function () {
const $objTooltip = $(this);
if ($objTooltip.data('setObjTooltip')) {
return;
}
let tooltipClassText = $objTooltip.data('tooltip-class');
if (!tooltipClassText) {
tooltipClassText = '';
}
const template = `<div class="tooltip ${tooltipClassText}" role="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>`;
const oTooltip = new Tooltip(this, {
title: function () {
return trim(this.querySelector('.tooltiptext')?.innerHTML);
},
template: template,
html: true
});
oTooltip.show();
$objTooltip.data('setObjTooltip', true);
});
});
}
+99
View File
@@ -0,0 +1,99 @@
import jQuery from 'jquery';
import { activateFlip } from "@/legacy/activateFlip";
import { unwrap } from '@util/unwrap';
import { htmlReady } from '@util/htmlReady';
import { initTooltip } from './initTooltip';
import { exportWindow } from '@/util/exportWindow';
import { reloadWorldMap } from '@/map';
import { refreshMsg } from '@/msg';
exportWindow(jQuery, '$');
htmlReady(() => {
for(const refreshBtn of document.querySelectorAll('.refreshPage')){
refreshBtn.addEventListener('click', function () {
document.location.reload();
return false;
})
}
for(const openWindowBtn of document.querySelectorAll('.open-window')){
openWindowBtn.addEventListener('click', function (e) {
e.preventDefault();
console.log(e);
let target: HTMLElement | null = e.target as HTMLElement;
while (target !== null) {
if(target.tagName != 'a' && (target as HTMLAnchorElement).href){
break;
}
target = target.parentElement;
}
if (!target) {
return;
}
window.open((target as HTMLAnchorElement).href);
});
}
activateFlip();
initTooltip();
void reloadWorldMap({
hrefTemplate: 'b_currentCity.php?citylist={0}',
useCachedMap: true
});
setInterval(function() {
void refreshMsg();
}, 5000);
});
(() => {
let finInit = false;
let nationMsgBox!: HTMLElement;
let nationMsg!: HTMLElement;
let nationMsgHeight: number | undefined = undefined;
function init() {
if (finInit) {
return false;
}
const _nationMsgBox = document.getElementById('nation-msg-box');
if (!_nationMsgBox) {
return false;
}
finInit = true;
nationMsgBox = _nationMsgBox;
nationMsg = unwrap(document.getElementById('nation-msg'));
}
function onScroll() {
if (!finInit && !init()) return;
if (nationMsgBox.offsetWidth < nationMsg.offsetWidth) {
if (nationMsgHeight === undefined) {
nationMsgHeight = nationMsgBox.offsetHeight;
nationMsgBox.style.height = `${nationMsgHeight / 2}px`;
}
}
else {
if (nationMsgBox.style.height) {
nationMsgHeight = undefined;
nationMsgBox.style.height = '';
}
}
}
htmlReady(() => {
init();
onScroll();
window.addEventListener('scroll', onScroll, true);
window.addEventListener('orientationchange', onScroll, true);
});
})();
+790
View File
@@ -0,0 +1,790 @@
import axios from 'axios';
import $ from 'jquery';
import { isNumber, merge } from 'lodash';
import { convColorValue, convertDictById, stringFormat } from '@/common_legacy';
import type { InvalidResponse, MapResult } from '@/defs';
import { unwrap } from "@util/unwrap";
import { convertFormData } from '@util/convertFormData';
import { exportWindow } from '@util/exportWindow';
import { htmlReady } from './util/htmlReady';
import { initTooltip } from './legacy/initTooltip';
declare const staticValues: {
serverNick: string;
serverID: string;
}
const { serverNick, serverID } = staticValues;
type CityPositionMap = {
[cityID: number]: [string, number, number];
}
declare global {
interface Window {
sam_toggleSingleTap?: boolean,
getCityPosition: () => CityPositionMap;
formatCityInfo: (city: MapCityParsedRaw) => MapCityParsedRegionLevelText
}
}
type MapCityParsedRaw = {
id: number,
level: number,
state: number,
nationID?: number,
region: number,
supply: boolean
};
type MapCityParsedName = MapCityParsedRaw & {
name: string,
x: number,
y: number,
};
type MapCityParsedNation = MapCityParsedName & {
nationID?: number;
nation?: string;
color?: string;
isCapital: boolean
}
type MapCityParsedClickable = MapCityParsedNation & {
clickable: number
}
type MapCityParsedRegionLevelText = MapCityParsedClickable & {
region_str: string,
level_str: string,
text: string,
}
export type MapCityParsed = MapCityParsedRegionLevelText;
type MapCityDrawable = {
cityList: MapCityParsed[],
myCity?: number
};
type MapNationParsed = {
id: number,
name: string,
color: string,
capital: number
}
type MapRawResult = InvalidResponse | MapResult;
function is_touch_device(): boolean {
const prefixes = ' -webkit- -moz- -o- -ms- '.split(' ');
const mq = (query: string) => {
return window.matchMedia(query).matches;
}
type TouchWindow = { DocumentTouch?: () => void | Document };
const tWindow = window as unknown as TouchWindow;
if (('ontouchstart' in window) || (tWindow.DocumentTouch && (document instanceof tWindow.DocumentTouch))) {
return true;
}
// include the 'heartz' as a way to have a non matching MQ to help terminate the join
// https://git.io/vznFH
const query = ['(', prefixes.join('touch-enabled),('), 'heartz', ')'].join('');
return mq(query);
}
export type loadMapOption = {
isDetailMap?: boolean, //복잡 지도, 단순 지도
clickableAll?: boolean, //어떤 경우든 클릭을 가능하게 함. 해당 동작의 동작 가능성 여부와는 별도.
selectCallback?: (city: MapCityParsed) => void, //callback을 지정시 clickable과 관계 없이 해당 함수를 실행.
hrefTemplate?: string, //도시가 클릭가능할 경우 지정할 href값. {0}은 도시 id로 변환됨
useCachedMap?: boolean, //맵 캐시를 사용
//아래부터는 post query에 들어갈 녀석
year?: number, //year값, 연감등에 사용
month?: number, //month값, 연감등에 사용
aux?: Record<number | string, unknown>, //기타 넣고 싶은 값을 입력
neutralView?: boolean, //clickable, 소속 국가, 첩보 여부 등을 반환여부를 설정
showMe?: boolean, //반환 값에 본인이 위치한 도시 값을 반환하도록 설정. neutralView와 별개
targetJson?: string,
reqType?: 'get' | 'post',
dynamicMapTheme?: boolean,
callback?: (a: MapCityDrawable, rawObejct: MapRawResult) => void,
//기타 보조 값
startYear?: number,
}
export async function reloadWorldMap(option: loadMapOption, drawTarget = '.world_map'): Promise<void> {
const $world_map = $(drawTarget);
if ($world_map.length == 0) {
return;
}
const defaultOption: loadMapOption = {
isDetailMap: true, //복잡 지도, 단순 지도
clickableAll: false, //어떤 경우든 클릭을 가능하게 함. 해당 동작의 동작 가능성 여부와는 별도.
selectCallback: undefined, //callback을 지정시 clickable과 관계 없이 해당 함수를 실행.
hrefTemplate: '#', //도시가 클릭가능할 경우 지정할 href값. {0}은 도시 id로 변환됨
useCachedMap: false, //맵 캐시를 사용
//아래부터는 post query에 들어갈 녀석
year: undefined, //year값, 연감등에 사용
month: undefined, //month값, 연감등에 사용
aux: undefined, //기타 넣고 싶은 값을 입력
neutralView: false, //clickable, 소속 국가, 첩보 여부 등을 반환여부를 설정
showMe: true, //반환 값에 본인이 위치한 도시 값을 반환하도록 설정. neutralView와 별개
targetJson: 'j_map.php',
reqType: 'post',
dynamicMapTheme: false,
callback: undefined,
//기타 보조 값
startYear: undefined,
};
option = merge({}, defaultOption, option);
const useCachedMap = option.useCachedMap;
const isDetailMap = option.isDetailMap;
const clickableAll = option.clickableAll;
const selectCallback = option.selectCallback;
const hrefTemplate = unwrap(option.hrefTemplate);
const cityPosition = window.getCityPosition();
const storedOldMapKey = `sam.${serverNick}.map`;
const storedStartYear = `sam.${serverNick}.startYear`;
//OBJ : startYear, year, month, cityList, nationList, spyList, shownByGeneralList, myCity
async function checkReturnObject(obj: MapRawResult): Promise<MapResult> {
if (!obj.result) {
throw `fail: ${obj.reason}`;
}
if (!isNumber(obj.startYear) ||
!isNumber(obj.year) ||
!isNumber(obj.month)
) {
throw 'fail: date type';
}
if (useCachedMap) {
localStorage.setItem(storedOldMapKey, JSON.stringify([serverID, obj]));
localStorage.setItem(storedStartYear, JSON.stringify(obj.startYear));
}
$world_map.removeClass('draw_required');
return obj;
}
async function setMapBackground(obj: MapResult): Promise<MapResult> {
function setTheme() {
const oldTheme = $world_map.data('currentTheme');
const newTheme = obj.theme;
if (oldTheme === newTheme) {
return;
}
if (oldTheme) {
$world_map.removeClass('map_theme_' + oldTheme);
}
$world_map.addClass('map_theme_' + newTheme);
$world_map.data('currentTheme', newTheme ?? '_current');//FIXME: 뭔가 틀렸음. 전송시에 theme가 있어야하나?
}
if (option.dynamicMapTheme) {
setTheme();
}
const startYear = obj.startYear;
const year = obj.year;
const month = obj.month;
if (isDetailMap) {
$world_map.addClass('map_detail').removeClass('map_basic');
} else {
$world_map.addClass('map_basic').removeClass('map_detail');
}
const $map_title = $('.map_title_text');
if (year < startYear + 1) {
$map_title.css('color', 'magenta');
} else if (year < startYear + 2) {
$map_title.css('color', 'orange');
} else if (year < startYear + 3) {
$map_title.css('color', 'yellow');
}
const $map_title_tooltip = $('.map_title .tooltiptext');
$map_title_tooltip.empty();
const tooltipTexts = [];
if (year < startYear + 3) {
const startYearText = [];
let remainYear = startYear + 3 - year;
const remainMonth = 12 - month + 1;
if (remainMonth > 0) {
remainYear -= 1;
}
if (remainYear) {
startYearText.push(`${remainYear}`);
}
if (remainMonth) {
startYearText.push(`${remainMonth}개월`);
}
tooltipTexts.push(`초반제한 기간 : ${startYearText.join(' ')} (${startYear + 3}년)`);
}
const currentTechLimit = Math.floor(Math.max(0, year - startYear) / 5) + 1;
const nextTechLimitYear = currentTechLimit * 5 + startYear;
tooltipTexts.push(`기술등급 제한 : ${currentTechLimit}등급 (${nextTechLimitYear}년 해제)`);
$map_title_tooltip.html(tooltipTexts.join('<br>'));
$world_map.removeClass('map_string map_summer map_fall map_winter');
if (month <= 3) {
$world_map.addClass('map_spring');
} else if (month <= 6) {
$world_map.addClass('map_summer');
} else if (month <= 9) {
$world_map.addClass('map_fall');
} else {
$world_map.addClass('map_winter');
}
$map_title.html(`${year}${month}`);
return obj;
}
async function convertCityObjs(obj: MapResult): Promise<MapCityDrawable> {
//원본 Obj는 굉장히 간소하게 온다, Object 형태로 변환해서 사용한다.
function toCityObj([id, level, state, nationID, region, supply]: MapResult['cityList'][0]): MapCityParsedRaw {
return {
id: id,
level: level,
state: state,
nationID: nationID > 0 ? nationID : undefined,
region: region,
supply: supply != 0
};
}
function toNationObj([id, name, color, capital]: MapResult['nationList'][0]): MapNationParsed {
return {
id,
name,
color,
capital
};
}
const nationList = convertDictById(obj.nationList.map(toNationObj)); //array of object -> dict
const spyList = obj.spyList;
const shownByGeneralList = new Set(obj.shownByGeneralList);
const myCity = obj.myCity;
const myNation = obj.myNation;
function mergePositionInfo(city: MapCityParsedRaw): MapCityParsedName {
const id = city.id;
if (!(id in cityPosition)) {
throw TypeError(`알수 없는 cityID: ${id}`);
}
const [name, x, y] = cityPosition[id];
return {
...city,
name,
x,
y,
};
}
function mergeNationInfo(city: MapCityParsedName): MapCityParsedNation {
//nationID 값으로 isCapital, color, nation을 통합
const nationID = city.nationID;
if (nationID === undefined || !(nationID in nationList)) {
return {
...city,
isCapital: false,
};
}
const nationObj = nationList[nationID];
return {
...city,
nation: nationObj.name,
color: nationObj.color,
isCapital: nationObj.capital == city.id
};
}
function mergeClickable(city: MapCityParsedNation): MapCityParsedClickable {
//clickable = (defaultCity << 4 ) | (remainSpy << 3) | (ourCity << 2) | (shownByGeneral << 1) | clickableAll
const id = city.id;
const nationID = city.nationID;
let clickable = 16;
if (id in spyList) {
clickable |= spyList[id] << 3;
}
if (myNation !== null && nationID == myNation) {
clickable |= 4;
}
if (shownByGeneralList.has(id)) {
clickable |= 2;
}
if (myCity !== null && id == myCity) {
clickable |= 2;
}
if (clickableAll) {
clickable |= 1;
}
return {
...city,
clickable
};
}
const cityList = obj.cityList.map(toCityObj)
.map(mergePositionInfo)
.map(mergeNationInfo)
.map(mergeClickable)
.map(window.formatCityInfo);
return {
'cityList': cityList,
'myCity': myCity
};
}
function drawDetailWorldMap(obj: MapCityDrawable) {
const $map_body = $(drawTarget + ' .map_body');
const cityList = obj.cityList;
const myCity = obj.myCity;
cityList.forEach(function (city) {
const id = city.id;
$(`.city_base_${id}`).detach();
//이전 도시는 지운다.
const $cityObj = $(`<div class="city_base city_base_${id}"></div>`);
$cityObj.addClass(`city_level_${city.level}`);
$cityObj.data('obj', city).css({ 'left': city.x - 20, 'top': city.y - 15 });
if (city.color !== undefined) {
const $bgObj = $('<div class="city_bg"></div>');
$cityObj.append($bgObj);
$bgObj.css({ 'background-image': `url(${window.pathConfig.gameImage}/b${convColorValue(city.color)}.png)` });
}
const $linkObj = $('<a class="city_link"></a>');
$linkObj.data({ 'text': city.text, 'nation': city.nation, 'id': city.id });
$cityObj.append($linkObj);
const $imgObj = $(`<div class="city_img"><img src="${window.pathConfig.gameImage}/cast_${city.level}.gif"><div class="city_filler"></div></div>`);
$linkObj.append($imgObj);
if (city.state > 0) {
const $stateObj = $(`<div class="city_state"><img src="${window.pathConfig.gameImage}/event${city.state}.gif"></div>`);
$linkObj.append($stateObj);
}
if (city.nationID && city.nationID > 0) {
const flagType = city.supply ? 'f' : 'd';
const $flagObj = $(`<div class="city_flag"><img src="${window.pathConfig.gameImage}/${flagType}${convColorValue(unwrap(city.color))}.gif"></div>`);
if (city.isCapital) {
const $capitalObj = $(`<div class="city_capital"><img src="${window.pathConfig.gameImage}/event51.gif"></div>`);
$flagObj.append($capitalObj);
}
$imgObj.append($flagObj);
}
const $nameObj = $(`<span class="city_detail_name">${city.name}</span>`);
$imgObj.append($nameObj);
$map_body.append($cityObj);
});
if (myCity) {
$world_map.find(`.city_base_${myCity} .city_filler`).addClass('my_city');
}
return obj;
}
function drawBasicWorldMap(obj: MapCityDrawable) {
const $map_body = $(`${drawTarget} .map_body`);
const cityList = obj.cityList;
const myCity = obj.myCity;
cityList.forEach(function (city) {
const id = city.id;
$(`.city_base_${id}`).detach();
//이전 도시는 지운다.
const $cityObj = $(`<div class="city_base city_base_${id}"></div>`);
$cityObj.addClass(`city_level_${city.level}`);
$cityObj.data('obj', city).css({ 'left': city.x - 20, 'top': city.y - 15 });
const $linkObj = $('<a class="city_link"></a>');
$linkObj.data({ 'text': city.text, 'nation': city.nation, 'id': city.id });
$cityObj.append($linkObj);
const $imgObj = $('<div class="city_img"><div class="city_filler"></div></div>');
if (city.color !== undefined) {
$imgObj.css({ 'background-color': city.color });
}
$linkObj.append($imgObj);
if (city.state > 0) {
let state_text = 'wrong';
if (city.state < 10) {
state_text = 'good';
} else if (city.state < 40) {
state_text = 'bad';
} else if (city.state < 50) {
state_text = 'war';
}
const $stateObj = $(`<div class="city_state city_state_${state_text}"></div>`);
$imgObj.append($stateObj);
}
//단순 표기에서는 깃발 여부가 없음
if (city.isCapital) {
const $capitalObj = $('<div class="city_capital"></div>');
$imgObj.append($capitalObj);
}
const $nameObj = $(`<span class="city_detail_name">${city.name}</span>`);
$imgObj.append($nameObj);
$map_body.append($cityObj);
});
if (myCity) {
$world_map.find(`.city_base_${myCity} .city_filler`).addClass('my_city');
}
return obj;
}
function setMouseWork(obj: MapCityDrawable) {
initTooltip($(drawTarget));
const $tooltip = $(drawTarget + ' .city_tooltip');
const $tooltip_city = $tooltip.find('.city_name');
const $tooltip_nation = $tooltip.find('.nation_name');
const $objs = $(drawTarget + ' .city_link');
const $map_body = $(drawTarget + ' .map_body');
//터치스크린 탭
if (!option.neutralView && is_touch_device()) {
$objs.on('touchstart', function () {
if (window.sam_toggleSingleTap) {
return true;
}
const $this = $(this);
const touchMode = $this.data('touchMode') as number;
if ($tooltip_city.data('target') != $this.data('id')) {
$this.data('touchMode', 1);
} else if (touchMode === undefined) {
$this.data('touchMode', 1);
} else {
$this.data('touchMode', touchMode + 1);
}
$map_body.data('touchMode', 1);
$tooltip_city.data('target', $this.data('id'));
});
$objs.on('touchend', function () {
if (window.sam_toggleSingleTap) {
return true;
}
const $this = $(this);
const position = $this.parent().position();
$tooltip_city.html($this.data('text') as string);
const nation_text = $this.data('nation') as string;
if (nation_text) {
$tooltip_nation.html(nation_text).show();
} else {
$tooltip_nation.html('').hide();
}
const left = position.left;
const top = position.top;
const tooltipWidth = unwrap($tooltip.width());
if (left + tooltipWidth + 35 > window.innerWidth) {
$tooltip.css({ 'top': top + 45, 'left': left - tooltipWidth - 10 }).show();
}
else {
$tooltip.css({ 'top': top + 45, 'left': left + 35 }).show();
}
const touchMode = $this.data('touchMode') as number;
if (touchMode <= 1) {
return false;
}
//xxx: touchend 다음 click 이벤트가 갈 수도 있고, 안 갈 수도 있다.
$this.data('touchMode', 0);
});
$map_body.on('touchend', function () {
if (window.sam_toggleSingleTap) {
return true;
}
//위의 touchend bind에 해당하지 않는 경우 -> 빈 지도 터치
$tooltip.hide();
});
}
//Mouse over 모드 작동
$map_body.on('mousemove', function (e) {
if ($(this).data('touchMode')) {
return true;
}
const rect = this.getBoundingClientRect();
const left = (e.clientX - rect.left - this.clientLeft + this.scrollLeft);
const top = (e.clientY - rect.top - this.clientTop + this.scrollTop);
const tooltipWidth = unwrap($tooltip.width());
if (e.clientX + rect.left + tooltipWidth + 10 > window.innerWidth) {
$tooltip.css({ 'top': top + 30, 'left': left - tooltipWidth - 10 });
}
else {
$tooltip.css({ 'top': top + 30, 'left': left + 10 });
}
});
$objs.on('mouseenter', function () {
if ($map_body.data('touchMode')) {
return true;
}
const $this = $(this);
$tooltip_city.data('target', $this.data('id'));
$tooltip_city.html($this.data('text'));
const nation_text = $this.data('nation');
if (nation_text) {
$tooltip_nation.html(nation_text).show();
} else {
$tooltip_nation.html('').hide();
}
$tooltip.show();
});
$objs.on('mouseleave', function () {
$tooltip.hide();
});
$objs.on('click', function () {
//xxx: touchend 다음 click 이벤트가 갈 수도 있고, 안 갈 수도 있다.
const touchMode = $(this).data('touchMode') as number | undefined;
if (touchMode === undefined) {
return;
}
if (touchMode === 1) {
return false;
}
});
return obj;
}
function setCityClickable(obj: MapCityDrawable) {
obj.cityList.forEach(function (city) {
const $cityLink = $world_map.find(`.city_base_${city.id} .city_link`);
if ('clickable' in city && city.clickable > 0) {
$cityLink.attr('href', stringFormat(hrefTemplate, city.id));
}
if (selectCallback) {
$cityLink.on('click', function (e) {
e.preventDefault();
return selectCallback(city);
});
}
});
return obj;
}
function saveCityInfo(obj: MapCityDrawable) {
$world_map.data('cityInfo', obj);
return obj;
}
const $hideCityNameBtn = $world_map.find('.map_toggle_cityname');
if (localStorage.getItem('sam.hideMapCityName') == 'yes') {
$world_map.addClass('hide_cityname');
$hideCityNameBtn.addClass('active').attr('aria-pressed', 'true');
}
$hideCityNameBtn.on('click', function () {
//이전 상태 확인
const state = localStorage.getItem('sam.hideMapCityName') == 'no';
if (state) {
$world_map.addClass('hide_cityname');
localStorage.setItem('sam.hideMapCityName', 'yes');
} else {
$world_map.removeClass('hide_cityname');
localStorage.setItem('sam.hideMapCityName', 'no');
}
});
const $toggleSingleTapBtn = $world_map.find('.map_toggle_single_tap');
if (localStorage.getItem('sam.toggleSingleTap') == 'yes') {
window.sam_toggleSingleTap = true;
$toggleSingleTapBtn.addClass('active').attr('aria-pressed', 'true');
} else {
window.sam_toggleSingleTap = false;
}
const $map_body = $(drawTarget + ' .map_body');
$toggleSingleTapBtn.on('click', function () {
//이전 상태 확인
const state = localStorage.getItem('sam.toggleSingleTap') == 'no';
if (state) {
$map_body.removeData('touchMode');
localStorage.setItem('sam.toggleSingleTap', 'yes');
window.sam_toggleSingleTap = true;
} else {
localStorage.setItem('sam.toggleSingleTap', 'no');
window.sam_toggleSingleTap = false;
}
});
if (isDetailMap) {
$world_map.addClass('map_detail');
} else {
$world_map.removeClass('map_datail');
}
const responseP = axios({
url: unwrap(option.targetJson),
method: unwrap(option.reqType),
responseType: 'json',
data: convertFormData({
data: JSON.stringify({
neutralView: option.neutralView,
year: option.year,
month: option.month,
showMe: option.showMe,
aux: option.aux
})
})
});
const response = await responseP;
const rawObject: MapRawResult = response.data;
const computedResult = await checkReturnObject(rawObject)
.then(setMapBackground)
.then(convertCityObjs)
.then(isDetailMap ? drawDetailWorldMap : drawBasicWorldMap)
.then(setMouseWork)
.then(setCityClickable)
.then(saveCityInfo);
if (option.callback) {
option.callback(computedResult, rawObject);
}
if ($world_map.hasClass('draw_required')) {
if (useCachedMap) {
//일단 불러옴
await (async () => {
const rawStoredMap = localStorage.getItem(storedOldMapKey);
if (!rawStoredMap) {
return;
}
const [storedServerID, storedMap] = JSON.parse(rawStoredMap) as [string, MapResult];
if (storedServerID != serverID) {
return;
}
await setMapBackground(storedMap)
.then(convertCityObjs)
.then(isDetailMap ? drawDetailWorldMap : drawBasicWorldMap)
.then(setMouseWork)
.then(setCityClickable)
.then(saveCityInfo);
})();
} else if (option.year && option.month) {
const rawStartYear = localStorage.getItem(storedStartYear) as string | undefined;
let startYear: number;
if (rawStartYear) {
startYear = JSON.parse(rawStartYear);
} else {
startYear = option.year;
}
await setMapBackground({
year: option.year,
month: option.month,
startYear: startYear
} as unknown as MapResult);
}
}
}
exportWindow(reloadWorldMap, 'reloadWorldMap');
htmlReady(function(){
if( is_touch_device()){
const target = document.querySelector('.map_body .map_toggle_single_tap') as HTMLElement | null;
if(!target){
return;
}
target.style.display = 'block';
}
});
+702
View File
@@ -0,0 +1,702 @@
import $ from 'jquery';
import type { InvalidResponse } from '@/defs';
import { getDateTimeNow } from '@util/getDateTimeNow';
import axios from 'axios';
import { convertFormData } from '@util/convertFormData';
import { isBrightColor } from "@util/isBrightColor";
import { unwrap } from '@util/unwrap';
import _ from 'lodash';
import { addMinutes } from 'date-fns';
import { parseTime } from '@util/parseTime';
import { formatTime } from '@util/formatTime';
import { TemplateEngine } from '@util/TemplateEngine';
import { isNotNull } from '@util/isNotNull';
import { unwrap_any } from '@util/unwrap_any';
import { setAxiosXMLHttpRequest } from '@util/setAxiosXMLHttpRequest';
import { exportWindow } from '@util/exportWindow';
const messageTemplate = `<div
class="msg_plate msg_plate_<%msgType%> msg_plate_<%nationType%>"
id="msg_<%id%>"
data-id="<%id%>"
>
<div class="msg_icon">
<%if(src.icon){ %>
<img class='generalIcon' width='64' height='64' src="<%encodeURI(src.icon)%>">
<%} else {%>
<img class='generalIcon' width='64' height='64' src="<%encodeURI(defaultIcon)%>">
<%}%>
</div>
<div class="msg_body">
<div class="msg_header">
<%if(!this.option.action && src.id == myGeneralID && now <= last5min && invalidType == 'msg_valid' && !deletable){%>
<button type="button" data-erase_until="<%last5min%>" class="btn btn btn-outline-warning btn-sm btn-delete-msg" style='float:right'>❌</button>
<%}%>
<%if(msgType == 'private') {%>
<%if(src.name == generalName){%>
<span class="msg_target msg_<%src.colorType%>" style="background-color:<%src.color%>;">나</span
><span class="msg_from_to">▶</span
><span class="msg_target msg_<%dest.colorType%>" style="background-color:<%dest.color%>;"><%e(dest.name)%>:<%dest.nation%></span>
<%}else{%>
<span class="msg_target msg_<%src.colorType%>" style="background-color:<%src.color%>;"><%e(src.name)%>:<%src.nation%></span
><span class="msg_from_to">▶</span
><span class="msg_target msg_<%dest.colorType%>" style="background-color:<%dest.color%>;">나</span>
<%}%>
<%} else if(msgType == 'national' && src.nation_id == dest.nation_id){%>
<span class="msg_target msg_<%src.colorType%>" style="background-color:<%src.color%>;"><%e(src.name)%></span>
<%} else if(msgType == 'national' || msgType == 'diplomacy'){%>
<%if(src.nation_id == nationID){%>
<span class="msg_target msg_<%src.colorType%>" style="background-color:<%src.color%>;"><%e(src.name)%></span
><span class="msg_from_to">▶</span
><span class="msg_target msg_<%dest.colorType%>" style="background-color:<%dest.color%>;"><%dest.nation%></span>
<%}else{%>
<span class="msg_target msg_<%src.colorType%>" style="background-color:<%src.color%>;"><%e(src.name)%>:<%src.nation%></span
><span class="msg_from_to"></span>
<%}%>
<%} else {%>
<span class="msg_target msg_<%src.colorType%>" style="background-color:<%src.color%>;"><%e(src.name)%>:<%src.nation%></span>
<%} %>
<span class="msg_time">&lt;<%e(time)%>&gt;</span>
</div>
<div class="msg_content <%invalidType%>"><%linkifyStr(text)%></div>
<%if(this.option && this.option.action) {%>
<div class="msg_prompt">
<button type="button" class="prompt_yes btn_prompt" <%allowButton?'':'disabled="disabled"'%>>수락</button> <button type="button" class="prompt_no btn_prompt" <%allowButton?'':'disabled="disabled"'%>>거절</button>
</div><%} %></div></div>`;
let myGeneralID: number | undefined;
//let isChief: boolean | undefined;
let lastSequence: number | undefined;
let myNation: {
id: number,
mailbox: number,
color: string,
nation: string,
} | undefined;
//let myOfficerLevel: number | undefined;
let permissionLevel: number | undefined;
type MsgType = 'private' | 'public' | 'national' | 'diplomacy';
const minMsgSeq: Record<MsgType, number> = {
'private': 0x7fffffff,
'public': 0x7fffffff,
'national': 0x7fffffff,
'diplomacy': 0x7fffffff,
}
type MsgTarget = {
id: number,
name: string,
nation_id: number, //XXX: 왜 이 값은 nationID가 아니고 nation_id인가?
nation: string;
color: string;
icon: string;
}
type MsgItem = {
id: number,
msgType: MsgType;
src: MsgTarget;
dest?: MsgTarget;
text: string,
option: Record<string, string | number>;
time: string;
}
type MsgPrintItem = MsgItem & {
generalName: string;
nationID: number;
nationType: 'local' | 'src' | 'dest';
myGeneralID: number;
allowButton: boolean;
last5min: string;
now: string;
invalidType: 'msg_invalid' | 'msg_valid';
deletable: boolean;
src: MsgTarget & { colorType: 'bright' | 'dark' },
dest: MsgTarget & { colorType: 'bright' | 'dark' },
defaultIcon: string,
}
type MsgResponse = {
[v in MsgType]: MsgItem[];
} & {
result: true;
keepRecent: boolean;
nationID: number;
generalName: string;
sequence: number;
};
type BasicGeneralTarget = {
id: number,
textName: string,
name?: string,
isRuler?: boolean,
nation?: number,
color?: string,
}
type MailboxItem = {
id: number,
mailbox: number,
color: string,
name: string,
nationID: number,
//nation: string,
general: [number, string, number][]
}
type MabilboxListResponse = {
result: true,
nation: MailboxItem[]
}
type BasicInfoResponse = {
generalID: number,
myNationID: number,
isChief: boolean,
officerLevel: number,
permission: number,
}
let generalList: Record<number, BasicGeneralTarget> = {};
async function responseMessage(msgID: number, responseAct: boolean): Promise<void> {
const response = await axios({
url: 'j_msg_decide_opt.php',
method: 'post',
responseType: 'json',
data: convertFormData({
data: JSON.stringify({
msgID: msgID,
response: responseAct
})
})
});
const result: InvalidResponse = response.data;
if (!result.result) {
alert(result.reason);
}
location.reload();
}
async function deleteMessage(msgID: number): Promise<MsgResponse> {
const response = await axios({
url: 'j_msg_delete.php',
method: 'post',
responseType: 'json',
data: convertFormData({
msgID: msgID,
})
});
const result: InvalidResponse | MsgResponse = response.data;
if (!result.result) {
throw result.reason;
}
return await refreshMsg();
}
export async function refreshMsg(): Promise<MsgResponse> {
const value = await fetchRecentMsg();
return redrawMsg(value, true);
}
exportWindow(refreshMsg, 'refreshMsg');
async function fetchRecentMsg(): Promise<MsgResponse> {
const response = await axios({
url: 'j_msg_get_recent.php',
method: 'post',
responseType: 'json',
data: convertFormData({
sequence: lastSequence ?? 0
})
});
const result: InvalidResponse | MsgResponse = response.data;
if (!result.result) {
throw result.reason;
}
return result;
}
async function showOldMsg(msgType: MsgType): Promise<MsgResponse> {
const response = await axios({
url: 'j_msg_get_old.php',
responseType: 'json',
method: 'post',
data: convertFormData({
to: minMsgSeq[msgType],
type: msgType,
})
});
const result: InvalidResponse | MsgResponse = response.data;
if (!result.result) {
throw result.reason;
}
return redrawMsg(result, false);
}
function redrawMsg(msgResponse: MsgResponse, addFront: boolean): MsgResponse {
function checkErasable(obj: MsgResponse) {
const now = getDateTimeNow();
$('.btn-delete-msg').each(function () {
const $btn = $(this);
const eraseUntil = $btn.data('erase_until');
if (eraseUntil < now) {
$btn.detach();
}
})
return obj;
}
function checkClear(obj: MsgResponse): MsgResponse {
if (!obj.keepRecent) {
$('.msg_plate').detach();
lastSequence = undefined;
console.log('refresh!');
void fetchRecentMsg().then(async (data) => {
redrawMsg(data, true);
})
throw true;
}
return obj;
}
function registerSequence(obj: MsgResponse): MsgResponse {
lastSequence = Math.max(lastSequence ?? 0, obj.sequence);
for (const msgType of ['public', 'private', 'national', 'diplomacy'] as MsgType[]) {
const msgList = obj[msgType];
if (!msgList || msgList.length == 0) {
continue;
}
const lastMsg = unwrap(_.last(msgList));
minMsgSeq[msgType] = Math.min(minMsgSeq[msgType], lastMsg.id);
}
return obj;
}
function printTemplate(obj: MsgResponse) {
const printList: [MsgItem[], JQuery<HTMLElement>, MsgType][] = [
[obj.public, $('#message_board .public_message'), 'public'],
[obj.private, $('#message_board .private_message'), 'private'],
[obj.diplomacy, $('#message_board .diplomacy_message'), 'diplomacy'],
[obj.national, $('#message_board .national_message'), 'national'],
];
for (const [msgSource, $msgBoard, msgType] of printList) {
if (!msgSource || $msgBoard.length == 0) {
console.log('No Items', msgSource, $msgBoard);
continue;
}
let needRefreshLastContact = (msgType == 'private');
const now = getDateTimeNow();
//list의 맨 앞이 가장 최신 메시지임.
const $msgs: JQuery<HTMLElement>[] = msgSource.map(function (msg) {
const contactTarget = (msg.src.id != myGeneralID || msg.msgType === 'public') ? msg.src.id : unwrap(msg.dest).id;
if (needRefreshLastContact && contactTarget != myGeneralID && contactTarget in generalList) {
needRefreshLastContact = false;
$('#last_contact').val(contactTarget).html(generalList[contactTarget].textName).show();
}
const nationID = obj.nationID;
const generalName = obj.generalName;
let nationType: MsgPrintItem["nationType"];
const src = {
...msg.src,
colorType: isBrightColor(msg.src.color) ? 'bright' as const : 'dark' as const,
};
if (!src.nation) {
src.nation = '재야';
src.color = '#000000';
}
const dest = {
...(msg.dest ?? msg.src),
colorType: isBrightColor((msg.dest ?? msg.src).color) ? 'bright' as const : 'dark' as const,
};
if (!dest.nation) {
dest.nation = '재야';
dest.color = '#000000';
}
if (src.nation_id == dest.nation_id) {
nationType = 'local';
}
else if (nationID == src.nation_id) {
nationType = 'src';
}
else {
nationType = 'dest';
}
const defaultIcon = `${window.pathConfig.sharedIcon}/default.jpg`;
let allowButton: boolean;
if (msgType == 'diplomacy') {
allowButton = unwrap(permissionLevel) >= 4;
}
else {
allowButton = true;
}
const last5min = formatTime(addMinutes(parseTime(msg.time), 5));
let invalidType: MsgPrintItem['invalidType'];
if (msg.option && msg.option.invalid) {
invalidType = 'msg_invalid';
}
else {
invalidType = 'msg_valid';
}
let deletable: boolean;
if (msg.option && !msg.option.deletable) {
deletable = false;
}
else {
deletable = true;
}
const printMsg: MsgPrintItem = {
...msg,
generalName,
nationID,
nationType,
myGeneralID: unwrap(myGeneralID),
src,
dest,
now,
allowButton,
last5min,
invalidType,
deletable,
defaultIcon,
msgType,
};
const msgHtml = TemplateEngine(unwrap(messageTemplate), printMsg);
//만약 이전 메시지와 같은 id가 온 경우 덮어씌운다.
//NOTE:현 프로세스 상에서는 같은 id가 와선 안된다.
const $existMsg = $(`#msg_${msg.id}`);
let $msg = $(msgHtml);
if ($existMsg.length) {
console.log('메시지 충돌', $msg, $existMsg);
$existMsg.html($msg.html());
$msg = $existMsg;
}
let hideMsg = false;
if (msg.option) {
if (msg.option.delete !== undefined) {
//delete는 삭제.
$(`#msg_${msg.option.delete}`).detach();
}
if (msg.option.overwrite !== undefined) {
//overwrite는 숨기기.
$.map(msg.option.overwrite, function (overwriteID) {
const $msg = $(`#msg_${overwriteID}`);
$msg.find('.btn-delete-msg').detach();
$msg.find('.msg_content').html('삭제된 메시지입니다.').removeClass('msg_valid').addClass('msg_invalid');
});
}
if (msg.option.hide) {
hideMsg = true;
}
}
if (hideMsg) {
return null;
}
$msg.find('.btn-delete-msg').click(function () {
if (!confirm("삭제하시겠습니까?")) {
return false;
}
void deleteMessage(msg.id);
});
$msg.find('button.prompt_yes').click(function () {
if (!confirm("수락하시겠습니까?")) {
return false;
}
void responseMessage(msg.id, true);
});
$msg.find('button.prompt_no').click(function () {
if (!confirm("거절하시겠습니까?")) {
return false;
}
void responseMessage(msg.id, false);
});
if ($existMsg.length) {
return null;
}
else {
return $msg;
}
}).filter(isNotNull);
if (addFront) {
$msgBoard.prepend($msgs);
}
else {
$msgBoard.find('.load_old_message').before($msgs);
}
}
}
msgResponse = checkErasable(msgResponse);
msgResponse = checkClear(msgResponse);
msgResponse = registerSequence(msgResponse);
printTemplate(msgResponse);
return msgResponse;
}
function refreshMailboxList(obj: MabilboxListResponse) {
const $mailboxList = $('#mailbox_list');
$mailboxList.change(function () {
console.log($(this).val());
})
const oldSelected = $mailboxList.val();
$mailboxList.empty();
let $lastContact = $('#last_contact');
let lastContact: BasicGeneralTarget | undefined;
if ($lastContact.length > 0 && parseInt(unwrap_any<string>($lastContact.val())) >= 0) {
lastContact = {
id: parseInt(unwrap_any<string>($lastContact.val())),
textName: $lastContact.html()
};
//$lastContact = undefined;
}
generalList = {};
obj.nation.sort(function (lhs, rhs) {
if (lhs.mailbox == unwrap(myNation).mailbox) {
return -1;
}
if (rhs.mailbox == unwrap(myNation).mailbox) {
return 1;
}
return lhs.mailbox - rhs.mailbox;
})
for (const nation of obj.nation) {
//console.log(nation);
const $optgroup = $(`<optgroup label="${nation.name}"></optgroup>`);
$optgroup.css('background-color', nation.color);
if (unwrap(myNation).mailbox == nation.mailbox) {
unwrap(myNation).color = nation.color;
}
if (isBrightColor(nation.color)) {
$optgroup.css('color', 'black');
}
else {
$optgroup.css('color', 'white');
}
nation.general.sort(function (lhs, rhs) {
if (lhs[1] < rhs[1]) {
return -1;
}
if (lhs[1] > rhs[1]) {
return 1;
}
return 0;
});
$.each(nation.general, function () {
const generalID = this[0];
const generalName = this[1];
//const isNPC = !!(this[2] & 0x2);
const isRuler = !!(this[2] & 0x1);
const isAmbassador = !!(this[2] & 0x4);
if (generalID == myGeneralID) {
return true;
}
let textName = generalName;
if (isRuler) {
textName = `*${textName}*`;
}
else if (isAmbassador) {
textName = `#${textName}#`;
}
generalList[generalID] = {
id: generalID,
name: generalName,
textName: textName,
isRuler: isRuler,
nation: nation.nationID,
color: nation.color
};
const $item = $(`<option value="${generalID}">${textName}</option>`);
if (unwrap(permissionLevel) == 4 && isAmbassador && unwrap(myNation).mailbox != nation.mailbox) {
$item.prop('disabled', true);
}
$optgroup.append($item);
});
$mailboxList.append($optgroup);
}
const $favorite = $('<optgroup label="즐겨찾기"></optgroup>');
//아국메시지, 전체메시지
const $ourCountry = $(`<option value="${unwrap(myNation).mailbox}">【 아국 메세지 】</option>`)
.css({ 'background-color': unwrap(myNation).color, 'color': isBrightColor(unwrap(myNation).color) ? 'black' : 'white' });
const $toPublic = $('<option value="9999">【 전체 메세지 】</option>');
$favorite.append($ourCountry);
$favorite.append($toPublic);
$lastContact = $('<option id="last_contact" value="-1"></option>').hide();
if (lastContact) {
$lastContact.show().val(lastContact.id).html(lastContact.textName);
}
$favorite.append($lastContact);
//TODO:운영자를 추가하는 코드도 넣을 것.
if (unwrap(permissionLevel) >= 4) {
for (const nation of obj.nation) {
//console.log(nation);
const $nation = $(`<option value="${nation.mailbox}">${nation.name}</option>`);
$nation.css('background-color', nation.color);
if (isBrightColor(nation.color)) {
$nation.css('color', 'black');
}
else {
$nation.css('color', 'white');
}
$favorite.append($nation);
}
}
$mailboxList.prepend($favorite);
if (!oldSelected) {
$mailboxList.val(unwrap(myNation).mailbox);
}
else {
$mailboxList.val(oldSelected);
}
}
function registerGlobal(basicInfo: BasicInfoResponse) {
myNation = {
'id': basicInfo.myNationID,
'mailbox': basicInfo.myNationID + 9000,
'color': '#000000',
'nation': '재야'
};
myGeneralID = basicInfo.generalID;
//isChief = basicInfo.isChief;
//myOfficerLevel = basicInfo.officerLevel;
permissionLevel = basicInfo.permission;
}
function activateMessageForm() {
const $msgInput = $('#msg_input');
const $msgSubmit = $('#msg_submit');
const $mailboxList = $('#mailbox_list');
$msgInput.keypress(function (e) {
const code = (e.keyCode ? e.keyCode : e.which);
if (code == 13) {
$msgSubmit.trigger('click');
return true;
}
});
$msgSubmit.click(async function () {
const text = $.trim(unwrap_any<string>($msgInput.val()));
$msgInput.val('').focus();
const targetMailbox = unwrap_any<string>($mailboxList.val());
console.log(targetMailbox, text);
const response = await axios({
url: 'j_msg_submit.php',
method: 'post',
responseType: 'json',
data: convertFormData({
data: JSON.stringify({
mailbox: parseInt(targetMailbox),
text: text
})
})
})
const result: InvalidResponse = response.data;
if (!result.result) {
alert(result.reason);
}
await refreshMsg();
});
}
$(async function ($) {
setAxiosXMLHttpRequest();
//basic_info.json은 세션값에 따라 동적으로 바뀌는 데이터임.
const basicInfoP = axios({
url: 'j_basic_info.php',
method: 'post',
responseType: 'json'
}).then((v) => registerGlobal(v.data));
//sender_list.json 은 서버측에선 캐시 가능한 데이터임.
const senderListP = axios({
url: 'j_msg_contact_list.php',
method: 'post',
responseType: 'json'
}).then(v => v.data);
const messageListP = fetchRecentMsg();
await basicInfoP
const senderList = await senderListP;
refreshMailboxList(senderList);
activateMessageForm();
const messageList = await messageListP;
redrawMsg(messageList, true);
$('.load_old_message').click(function () {
const $this = $(this);
const msgType = $this.data('msg_type');
void showOldMsg(msgType);
})
});
+277
View File
@@ -0,0 +1,277 @@
import "@scss/myPage.scss";
import axios from 'axios';
import $ from 'jquery';
import { type InvalidResponse, keyScreenMode, type ScreenModeType, type ItemTypeKey } from '@/defs';
import { convertFormData } from '@util/convertFormData';
import { setAxiosXMLHttpRequest } from '@util/setAxiosXMLHttpRequest';
import { unwrap } from '@util/unwrap';
import { unwrap_any } from '@util/unwrap_any';
import { auto500px } from './util/auto500px';
import { initTooltip } from "./legacy/initTooltip";
import { insertCustomCSS } from "./util/customCSS";
import * as JosaUtil from '@util/JosaUtil';
import { SammoAPI } from "./SammoAPI";
type LogResponse = {
result: true;
log: Record<string, string>;
};
declare const staticValues: {
items: Record<ItemTypeKey, {
name: string;
rawName: string;
className: string;
cost: number;
isBuyable: boolean;
}>
}
$(function ($) {
setAxiosXMLHttpRequest();
const initCustomCSSForm = function () {
let lastTimeOut: NodeJS.Timeout | undefined = undefined;
const $obj = $('#custom_css');
const key = 'sam_customCSS';
let text = localStorage.getItem(key);
if (text) {
$obj.val(text);
console.log(text);
}
$obj.on('change keyup paste', function () {
const newText = $obj.val();
if (text == newText) {
return;
}
if (lastTimeOut) {
clearTimeout(unwrap(lastTimeOut));
}
$obj.css('background-color', '#222222');
lastTimeOut = setTimeout(function () {
text = unwrap_any<string>($obj.val());
localStorage.setItem(key, text);
$obj.css('background-color', 'black');
}, 500);
})
;
};
$('.load_old_log').on('click', async function (e) {
e.preventDefault();
const $thisBtn = $(this);
const logType = $thisBtn.data('log_type');
const $last = $(`.log_${logType}:last`);
let reqTo: number | null = null;
if ($last.length) {
reqTo = $last.data('seq');
}
let result: InvalidResponse | LogResponse;
try {
const response = await axios({
url: 'j_general_log_old.php',
method: 'post',
responseType: 'json',
data: convertFormData({
to: reqTo,
type: logType
})
});
result = response.data;
}
catch (e) {
console.log(e);
alert(`실패했습니다: ${e}`);
return;
}
if (!result.result) {
alert(`로그를 받아오지 못했습니다. : ${result.reason}`);
location.reload();
return;
}
const keys: string[] = Object.keys(result.log);
if (keys.length > 1 && parseInt(keys[0]) < parseInt(keys[1])) {
keys.reverse();
}
if (keys.length == 0) {
$thisBtn.hide();
return;
}
const html: string[] = [];
for (const key of keys) {
if ($(`#log_${logType}_${key}`).length) {
return true;
}
const item = result.log[key];
html.push(`<div class='log_${logType}' id='log_${logType}_${key}' data-seq='${key}'>${item}</div>`);
}
$(`#${logType}Plate`).append(html.join(''));
})
initCustomCSSForm();
const $screenModeRadios = $('input:radio[name=screenMode]');
$screenModeRadios.prop('checked', false).filter(`[value="${localStorage.getItem(keyScreenMode) ?? 'auto'}"]`).prop('checked', true);
$screenModeRadios.on('click', function (e) {
const mode = (e.target as HTMLInputElement).value as ScreenModeType;
localStorage.setItem(keyScreenMode, mode);
document.dispatchEvent(new CustomEvent('tryChangeScreenMode'));
});
$('#dieOnPrestart').on('click', async function (e) {
e.preventDefault();
if (!confirm('정말로 삭제하시겠습니까?')) {
return false;
}
try {
await SammoAPI.General.DieOnPrestart();
}
catch (e) {
console.log(e);
alert(`실패했습니다: ${e}`);
location.reload();
return;
}
location.replace('..');
});
$('#buildNationCandidate').on('click', async function (e) {
e.preventDefault();
if(!confirm('거병 이후 장수를 삭제할 수 없게됩니다. 거병하시겠습니까?')){
return false;
}
try {
await SammoAPI.General.BuildNationCandidate();
}
catch (e) {
console.log(e);
alert(`실패했습니다: ${e}`);
location.reload();
return;
}
location.reload();
});
$('#vacation').on('click', async function (e) {
e.preventDefault();
if (!confirm('휴가 기능을 신청할까요?')) {
return false;
}
let result: InvalidResponse;
try {
const response = await axios({
url: 'j_vacation.php',
method: 'post',
responseType: 'json',
});
result = response.data;
}
catch (e) {
console.log(e);
alert(`실패했습니다: ${e}`);
location.reload();
return;
}
if (!result.result) {
alert(`실패했습니다: ${result.reason}`);
location.reload();
return;
}
location.reload();
});
$('#set_my_setting').on('click', async function (e) {
e.preventDefault();
let result: InvalidResponse;
try {
const response = await axios({
url: 'j_set_my_setting.php',
method: 'post',
responseType: 'json',
data: convertFormData({
tnmt: unwrap_any<string>($('.tnmt:checked').val()),
defence_train: parseInt(unwrap_any<string>($('#defence_train').val())),
use_treatment: unwrap_any<string>($('#use_treatment').val()),
use_auto_nation_turn: unwrap_any<string>($('#use_auto_nation_turn').val()),
})
});
result = response.data;
if (!result.result) {
throw result.reason;
}
}
catch (e) {
console.log(e);
alert(`실패했습니다: ${e}`);
location.reload();
return;
}
location.reload();
});
$('.drop-item-btn').on('click', async function (e) {
e.preventDefault();
const $this = $(this);
const type = $this.data('item-type') as ItemTypeKey | undefined;
if (!type) {
return;
}
console.log(`${type} 판매 시도`);
const item = staticValues.items[type];
console.log(item);
const josaUl = JosaUtil.pick(item.rawName, '을');
if (!confirm(`${item.name}${josaUl} 버리시겠습니까? (판매시 가치: ${item.cost / 2})`)) {
return;
}
if (!item.isBuyable && !confirm(`이 아이템은 유니크 아이템입니다. 진짜로 ${item.name}${josaUl} 버리시겠습니까?`)) {
return;
}
try {
await SammoAPI.General.DropItem({
itemType: type,
});
alert(`${item.name}${josaUl} 버렸습니다.`);
location.reload();
}
catch (e) {
console.error(e);
alert(e);
}
});
initTooltip();
insertCustomCSS();
});
auto500px();
@@ -0,0 +1,39 @@
<template>
<div v-for="(cityList, distance) in distanceList" :key="distance">
{{ distance }} 떨어진 도시:
<template v-for="(cityID, key) in cityList" :key="key">
<template v-if="key !== 0"> , </template>
<a
:style="{ color: colorMap[distance as keyof typeof colorMap] ?? undefined, textDecoration:'underline' }"
@click="$emit('selected', cityID)"
>{{ citiesMap.get(cityID)?.name }}</a
>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
export default defineComponent({
props: {
distanceList: {
type: Object as PropType<Record<number, number[]>>,
required: true,
},
citiesMap: {
type: Object as PropType<Map<number, { name: string }>>,
required: true,
},
},
emits: ["selected"],
data() {
return {
colorMap: {
1: "magenta",
2: "orange",
3: "yellow",
},
};
},
});
</script>
+112
View File
@@ -0,0 +1,112 @@
<template>
<div class="crewTypeItem crewTypeSubGrid text-center s-border-b">
<div
class="crewTypeImg"
:style="{
background: '#222222 no-repeat center',
backgroundImage: `url('${crewType.img}')`,
backgroundSize: '64px',
outline: 'solid 1px gray',
}"
/>
<div
:style="{
backgroundColor: crewType.notAvailable ? 'red' : crewType.reqTech == 0 ? 'green' : 'limegreen',
height: '100%',
}"
class="d-grid crewTypeName"
>
<div style="margin: auto">{{ crewType.name }}</div>
</div>
<div>{{ crewType.attack }}</div>
<div>{{ crewType.defence }}</div>
<div>{{ crewType.speed }}</div>
<div>{{ crewType.avoid }}</div>
<div>{{ crewType.baseCost.toFixed(1) }}</div>
<div>{{ crewType.baseRice.toFixed(1) }}</div>
<div class="crewTypePanel">
<b-button-group>
<b-button class="py-1" variant="dark" @click="beHalf">절반</b-button>
<b-button class="py-1" variant="dark" @click="beFilled">채우기</b-button>
<b-button class="py-1" variant="dark" @click="beFull">가득</b-button>
</b-button-group>
<div class="row">
<div class="col mx-2">
<div class="input-group my-0">
<span class="input-group-text py-1">병력</span>
<input v-model.number="amount" type="number" class="form-control py-1 f_tnum px-0 text-end" min="1" />
<span class="input-group-text py-1 f_tnum">00</span>
<span
class="input-group-text py-1 f_tnum"
style="text-align: right; min-width: 10ch; color: #303030; background-color: #ddd"
>
<div style="margin-left: auto">
{{ Math.ceil(amount * crewType.baseCost * goldCoeff).toLocaleString() }}
</div>
</span>
</div>
</div>
</div>
</div>
<div class="crewTypeBtn d-grid">
<b-button variant="primary" @click="doSubmit">{{ commandName }}</b-button>
</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="crewTypeInfo text-start" v-html="crewType.info.join('<br>')" />
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from "vue";
import VueTypes from "vue-types";
export default defineComponent({
props: {
crewType: VueTypes.object.isRequired,
leadership: VueTypes.number.isRequired,
commandName: VueTypes.string.isRequired,
currentCrewType: VueTypes.number.def(-1),
crew: VueTypes.number.def(0),
goldCoeff: VueTypes.number.isRequired,
},
emits: ["submitOutput", "update:amount"],
setup(props, { emit }) {
const amount = ref(0);
function beHalf() {
amount.value = Math.ceil(props.leadership * 0.5);
}
function beFilled() {
if (props.crewType.id == props.currentCrewType) {
amount.value = Math.max(1, props.leadership - Math.floor(props.crew / 100));
} else {
amount.value = props.leadership;
}
}
function beFull() {
amount.value = Math.floor(props.leadership * 1.2);
}
function doSubmit(e: Event) {
emit("submitOutput", e, amount.value, props.crewType.id);
}
beFilled();
return {
amount,
beHalf,
beFilled,
beFull,
doSubmit,
};
},
watch: {
amount(val: number) {
this.$emit("update:amount", val);
},
},
});
</script>
+97
View File
@@ -0,0 +1,97 @@
<template>
<TopBackBar :title="commandName" />
<div v-if="!available건국" class="bg0"> 이상 건국은 불가능합니다.</div>
<div v-else class="bg0">
<div>현재 도시에서 나라를 세웁니다. , 소도시에서만 가능합니다.</div>
<ul>
<li v-for="nationType in nationTypes" :key="nationType.type" class="row">
<div class="col-2 col-md-1">- {{ nationType.name }}</div>
<div class="col-4 col-md-2">
: <span style="color: cyan">{{ nationType.pros }}</span
>,
</div>
<div class="col-4 col-md-2">
<span style="color: magenta">{{ nationType.cons }}</span>
</div>
</li>
</ul>
<div class="row">
<div class="col-4 col-md-2">국명 : <b-form-input v-model="destNationName" maxlength="18" /></div>
<div class="col-3 col-md-2">색상 : <ColorSelect v-model="selectedColorID" :colors="colors" /></div>
<div class="col-3 col-md-2">
<label>성향 :</label>
<b-form-select v-model="selectedNationType" :options="nationTypesOption" />
</div>
<div class="col-2 col-md-2 d-grid">
<b-button @click="submit">
{{ commandName }}
</b-button>
</div>
</div>
</div>
<BottomBar :title="commandName" />
</template>
<script lang="ts">
import ColorSelect from "@/processing/SelectColor.vue";
import { defineComponent, ref } from "vue";
import { unwrap } from "@/util/unwrap";
import type { Args } from "@/processing/args";
import TopBackBar from "@/components/TopBackBar.vue";
import BottomBar from "@/components/BottomBar.vue";
import type { procNationTypeList } from "../processingRes";
declare const commandName: string;
declare const procRes: {
available건국: boolean;
colors: string[];
nationTypes: procNationTypeList;
};
export default defineComponent({
components: {
ColorSelect,
TopBackBar,
BottomBar,
},
setup() {
const destNationName = ref("");
const selectedColorID = ref(0);
const selectedNationType = ref(Object.values(procRes.nationTypes)[0].type);
async function submit(e: Event) {
const event = new CustomEvent<Args>("customSubmit", {
detail: {
colorType: selectedColorID.value,
nationName: destNationName.value,
nationType: selectedNationType.value,
},
});
unwrap(e.target).dispatchEvent(event);
}
const nationTypesOption: { html: string; value: string }[] = [];
for (const nationType of Object.values(procRes.nationTypes)) {
nationTypesOption.push({
html: nationType.name,
value: nationType.type,
});
}
return {
available건국: procRes.available건국,
selectedColorID,
selectedNationType,
colors: procRes.colors,
nationTypes: procRes.nationTypes,
nationTypesOption,
destNationName,
commandName,
submit,
};
},
});
</script>
@@ -0,0 +1,73 @@
<template>
<TopBackBar :title="commandName" />
<div class="bg0">
<div>자신의 군량을 사거나 팝니다.<br /></div>
<div class="row">
<div class="col-2 col-md-1">
군량을 :
<b-button-group>
<b-button :pressed="buyRice" @click="buyRice = true"> </b-button>
<b-button :pressed="!buyRice" @click="buyRice = false"> </b-button>
</b-button-group>
</div>
<div class="col-7 col-md-4">
금액 :
<SelectAmount v-model="amount" :amountGuide="amountGuide" :maxAmount="maxAmount" :minAmount="minAmount" />
</div>
<div class="col-3 col-md-2 d-grid">
<b-button variant="primary" @click="submit">
{{ commandName }}
</b-button>
</div>
</div>
</div>
<BottomBar :title="commandName" />
</template>
<script lang="ts">
import SelectAmount from "@/processing/SelectAmount.vue";
import { defineComponent, ref } from "vue";
import { unwrap } from "@/util/unwrap";
import type { Args } from "@/processing/args";
import TopBackBar from "@/components/TopBackBar.vue";
import BottomBar from "@/components/BottomBar.vue";
declare const commandName: string;
declare const procRes: {
minAmount: number;
maxAmount: number;
amountGuide: number[];
};
export default defineComponent({
components: {
SelectAmount,
TopBackBar,
BottomBar,
},
setup() {
const amount = ref(1000);
const buyRice = ref(true);
async function submit(e: Event) {
const event = new CustomEvent<Args>("customSubmit", {
detail: {
amount: amount.value,
buyRice: buyRice.value,
},
});
unwrap(e.target).dispatchEvent(event);
}
return {
buyRice,
amount,
minAmount: ref(procRes.minAmount),
maxAmount: ref(procRes.maxAmount),
amountGuide: procRes.amountGuide,
commandName,
submit,
};
},
});
</script>
+97
View File
@@ -0,0 +1,97 @@
<template>
<TopBackBar v-model:searchable="searchable" :title="commandName" />
<div class="bg0">
<div>
재야나 타국의 장수를 등용합니다.<br />
서신은 개인 메세지로 전달됩니다.<br />
등용할 장수를 목록에서 선택하세요.<br />
</div>
<div class="row">
<div class="col-12 col-md-6">
장수 :
<SelectGeneral
v-model="selectedGeneralID"
:generals="generalList"
:groupByNation="nationList"
:textHelper="textHelpGeneral"
:searchable="searchable"
/>
</div>
<div class="col-4 col-md-2 d-grid">
<b-button variant="primary" @click="submit">
{{ commandName }}
</b-button>
</div>
</div>
</div>
<BottomBar :title="commandName" />
</template>
<script lang="ts">
import SelectGeneral from "@/processing/SelectGeneral.vue";
import { defineComponent, ref } from "vue";
import { unwrap } from "@/util/unwrap";
import type { Args } from "@/processing/args";
import TopBackBar from "@/components/TopBackBar.vue";
import BottomBar from "@/components/BottomBar.vue";
import {
convertGeneralList,
getProcSearchable,
type procGeneralItem,
type procGeneralKey,
type procGeneralRawItemList,
type procNationItem,
type procNationList,
} from "../processingRes";
import { getNpcColor } from "@/common_legacy";
declare const commandName: string;
declare const procRes: {
generals: procGeneralRawItemList;
generalsKey: procGeneralKey[];
nationList: procNationList;
};
export default defineComponent({
components: {
SelectGeneral,
TopBackBar,
BottomBar,
},
setup() {
const generalList = convertGeneralList(procRes.generalsKey, procRes.generals);
const selectedGeneralID = ref(generalList[0].no);
function textHelpGeneral(gen: procGeneralItem): string {
const nameColor = getNpcColor(gen.npc);
const name = nameColor ? `<span style="color:${nameColor}">${gen.name}</span>` : gen.name;
return name;
}
async function submit(e: Event) {
const event = new CustomEvent<Args>("customSubmit", {
detail: {
destGeneralID: selectedGeneralID.value,
},
});
unwrap(e.target).dispatchEvent(event);
}
const nationList = new Map<number, procNationItem>();
for (const nationItem of procRes.nationList) {
nationList.set(nationItem.id, nationItem);
}
return {
searchable: getProcSearchable(),
selectedGeneralID,
generalList,
nationList,
commandName,
textHelpGeneral,
submit,
};
},
});
</script>

Some files were not shown because too many files have changed in this diff Show More